File size: 37,933 Bytes
3d20eb8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
<!doctype html>
<meta charset="utf-8">
<title>Harbor runs</title>
<style>
:root{
  --bg:#0d1117;--panel:#161b22;--panel2:#0e1116;--border:#30363d;--text:#e6edf3;
  --muted:#8b949e;--gray:#6e7681;--accent:#58a6ff;--pass:#3ec97a;--fail:#e25c5c;--warn:#e6b455;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.5 ui-sans-serif,system-ui,sans-serif}
/* ── top bar ─────────────────────────────────────────────────────────── */
header{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--border);
       background:var(--panel);position:sticky;top:0;z-index:30}
header h1{font-size:13px;margin:0;font-weight:600;letter-spacing:.2px}
select,button{background:var(--panel2);color:var(--text);border:1px solid var(--border);border-radius:5px;
              padding:4px 8px;font:inherit;font-size:12px;cursor:pointer}
select:hover,button:hover{border-color:var(--accent)}
.view-btn.on{background:rgba(89,166,255,.14);border-color:var(--accent);color:var(--accent)}
.src{font:10.5px ui-monospace,monospace;color:var(--gray)}
.pill{font-size:10px;padding:1px 6px;border-radius:3px;border:1px solid var(--border);white-space:nowrap}
.pill.ok{background:rgba(62,201,122,.15);color:var(--pass);border-color:transparent}
.pill.exp{background:rgba(230,180,85,.15);color:var(--warn);border-color:transparent}
/* ── layout ──────────────────────────────────────────────────────────── */
main{display:grid;grid-template-columns:184px minmax(0,1fr);height:calc(100vh - 41px)}
.statwrap{border-bottom:1px solid var(--border)}
.statwrap>summary{cursor:pointer;padding:7px 14px;font-size:11.5px;color:var(--muted);list-style:none}
.statwrap>summary::-webkit-details-marker{display:none}
.statwrap>summary::before{content:"▸ ";color:var(--gray)}
.statwrap[open]>summary::before{content:"▾ "}
#burger{display:none}
/* Laptop-first: below 1100px the sidebar narrows, below 820px it becomes an overlay so the grid keeps
   the full width — a data-dense table is the thing that must not be squeezed. */
@media(max-width:1100px){
  main{grid-template-columns:150px minmax(0,1fr)}
  td.cell{width:52px}
  table.heatmap th.col-task,table.heatmap td.col-task{min-width:210px;max-width:210px}
}
@media(max-width:820px){
  #burger{display:inline-block}
  main{grid-template-columns:minmax(0,1fr)}
  aside{position:fixed;left:0;top:41px;bottom:0;width:210px;z-index:25;transform:translateX(-100%);
        transition:transform .15s;box-shadow:2px 0 12px rgba(0,0,0,.4)}
  body.nav-open aside{transform:none}
  .matrices{flex-direction:column}
  table.heatmap th.col-task,table.heatmap td.col-task{min-width:150px;max-width:150px}
}
aside{border-right:1px solid var(--border);overflow:auto;background:var(--panel2)}
.pgroup{padding:7px 10px 3px;font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--gray)}
.kid{padding:5px 10px 5px 16px;font-size:12px;color:var(--muted);cursor:pointer;border-left:2px solid transparent}
.kid:hover{background:var(--panel);color:var(--text)}
.kid.sel{background:rgba(89,166,255,.10);border-left-color:var(--accent);color:var(--text);font-weight:600}
section{overflow:auto;padding:0}
/* ── stats ───────────────────────────────────────────────────────────── */
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;padding:12px 14px 4px}
.stat{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px 12px}
.stat .num{font-size:17px;font-weight:600}
.stat .lbl{font-size:11px;color:var(--muted);margin-top:2px}
/* ── aggregate matrices ──────────────────────────────────────────────── */
.matrices{display:flex;flex-wrap:wrap;gap:10px;padding:10px 14px}
.matrix-card{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:11px 13px}
.matrix-card h4{margin:0 0 9px;font-size:12px;color:var(--muted);font-weight:500;display:flex;gap:8px}
.matrix-card h4 .sub{color:var(--gray);font-size:10.5px}
.matrix-card table{border-collapse:collapse;font-size:12px}
.matrix-card th,.matrix-card td{padding:5px 9px;border:1px solid var(--border);text-align:center;white-space:nowrap}
.matrix-card th{background:var(--panel);color:var(--muted);font-weight:500}
.matrix-card td.lbl{background:var(--panel);text-align:left;font-size:11.5px}
.matrix-card td.cell b{font-size:13px}
.matrix-card td.cell .det{color:rgba(255,255,255,.55);font-size:10px}
.matrix-card td.empty{color:var(--gray)}
/* ── the heatmap ─────────────────────────────────────────────────────── */
.hwrap{overflow:auto;padding:4px 14px 24px}
table.heatmap{border-collapse:separate;border-spacing:0;font-size:12px}
table.heatmap th{background:var(--panel);color:var(--muted);font-weight:500;font-size:11px;
                 padding:5px 8px;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:6;white-space:nowrap}
table.heatmap td{border-bottom:1px solid var(--border);padding:0}
table.heatmap th.col-task,table.heatmap td.col-task{position:sticky;left:0;z-index:5;background:var(--panel2);
       min-width:280px;max-width:280px;padding:5px 9px;text-align:left;border-right:2px solid var(--border)}
table.heatmap thead th.col-task{z-index:8;background:var(--panel)}
td.col-task .tid{font:11px ui-monospace,monospace;color:var(--muted)}
td.col-task .q{color:var(--text);font-size:11.5px;display:-webkit-box;-webkit-line-clamp:2;
               -webkit-box-orient:vertical;overflow:hidden}
.diff{font-size:9.5px;text-transform:uppercase;font-weight:600;padding:1px 5px;border-radius:3px;margin-right:5px}
.diff.easy{background:rgba(62,201,122,.15);color:var(--pass)}
.diff.medium{background:rgba(230,180,85,.15);color:var(--warn)}
.diff.hard{background:rgba(226,92,92,.15);color:var(--fail)}
td.cell{width:62px;height:26px;cursor:pointer;text-align:center;font:11px ui-monospace,monospace;
        transition:filter .1s;border-left:1px solid var(--border)}
td.cell:hover{filter:brightness(1.45);outline:1px solid var(--accent);outline-offset:-1px}
/* pass@k gradient — darker green = passed on an earlier attempt */
td.cell.pass-1{background:rgba(62,201,122,.55);color:#fff;font-weight:600}
td.cell.pass-2{background:rgba(62,201,122,.40);color:#fff;font-weight:600}
td.cell.pass-3{background:rgba(62,201,122,.28);color:#fff;font-weight:600}
td.cell.pass-4{background:rgba(62,201,122,.18);color:#fff;font-weight:600}
td.cell.fail{background:rgba(226,92,92,.25);color:rgba(255,255,255,.85)}
td.cell.na{background:rgba(230,180,85,.16);color:var(--warn)}
td.cell.empty{background:transparent;color:var(--gray);cursor:default}
td.cell.empty:hover{filter:none;outline:0}
.filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 14px 2px;border-bottom:1px solid var(--border)}
.fchip{font-size:11px;padding:2px 7px;border-radius:11px;border:1px solid var(--border);background:var(--panel2);
       color:var(--muted);cursor:pointer;white-space:nowrap}
.fchip:hover{border-color:var(--accent);color:var(--text)}
.fchip.on{background:rgba(89,166,255,.16);border-color:var(--accent);color:var(--accent)}
.fkey{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--gray);margin-left:6px}
.kid input{margin-right:6px;vertical-align:-1px}
.ctrls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border)}
.ctrls label{font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px}
input[type=range]{width:120px;accent-color:var(--accent)}
.chartgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:8px;padding:8px 14px 24px}
.chartbox{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:6px 8px 2px}
.chartbox h5{margin:0 0 2px;font-size:11px;color:var(--muted);font-weight:500;display:flex;justify-content:space-between}
.chartbox h5 span{color:var(--gray);font-size:10px}
.rlegend{display:flex;flex-wrap:wrap;gap:10px;padding:4px 14px;font-size:11px}
.rlegend i{display:inline-block;width:16px;height:3px;border-radius:2px;margin-right:4px;vertical-align:2px}
.taskcard{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:10px}
.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}
.metagrid div{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 8px;font-size:11px}
.metagrid em{display:block;font-style:normal;color:var(--gray);font-size:10px}
.legend{display:flex;gap:12px;align-items:center;padding:6px 14px;font-size:10.5px;color:var(--muted)}
.legend i{display:inline-block;width:14px;height:11px;border-radius:2px;margin-right:3px;vertical-align:middle}
/* ── trace dialog ────────────────────────────────────────────────────── */
dialog{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:9px;
       width:min(1020px,95vw);max-height:90vh;padding:0}
dialog::backdrop{background:rgba(0,0,0,.65)}
.dhead{display:flex;align-items:center;gap:9px;padding:10px 14px;border-bottom:1px solid var(--border);
       background:var(--panel);position:sticky;top:0}
.dbody{padding:12px 14px;overflow:auto;max-height:calc(90vh - 48px)}
.trial-table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0}
.trial-table th,.trial-table td{padding:5px 8px;border-bottom:1px solid var(--border);text-align:left}
.trial-table .reward.pass{color:var(--pass);font-weight:600}
.trial-table .reward.fail{color:var(--fail)}
.trial-table .reward.na{color:var(--warn)}
.msg{border:1px solid var(--border);border-radius:6px;margin-bottom:6px;overflow:hidden}
.msg-head{background:var(--panel);padding:3px 9px;font:10.5px ui-monospace,monospace;color:var(--muted)}
.msg-body{margin:0;padding:7px 10px;white-space:pre-wrap;word-break:break-word;
          font:11px/1.5 ui-monospace,monospace;max-height:280px;overflow:auto}
.gold{color:var(--pass)}
.empty-msg{color:var(--gray);padding:34px;text-align:center}
svg{background:var(--panel2);border:1px solid var(--border);border-radius:6px}
</style>
<header>
  <button id="burger" title="Show/hide runs"></button>
  <select id="proj"></select>
  <button id="refresh" title="Re-read the bucket / data directory"></button>
  <span style="flex:1"></span>
  <span class="src">cols</span>
  <button id="axH" class="view-btn on">harness</button>
  <button id="axM" class="view-btn">model</button>
  <span class="src" id="src"></span>
</header>
<main>
  <aside id="tree"></aside>
  <section id="body"><div class="empty-msg">select a dataset</div></section>
</main>
<dialog id="dlg">
  <div class="dhead"><b id="dtitle"></b><span id="dpills"></span><span style="flex:1"></span>
    <button onclick="document.getElementById('dlg').close()">esc</button></div>
  <div class="dbody" id="dbody"></div>
</dialog>
<script>
const $=s=>document.querySelector(s);
let TREE=[],AXIS="harness",DS=null,SEL=null,KMAX=4;
// Active metadata filters: {key: Set(values)}. Metadata is whatever the suite recorded, so nothing is
// hardcoded — difficulty, package_tier and reward_mode are exactly the fields that differ between
// suites, and promoting any of them to a column breaks the viewer when a suite renames one.
let FILT={};
// Several training runs can be on screen at once, so selection is a set and the charts overlay.
// Comparing curves across runs is the whole reason to look at them side by side.
let RUNSEL=new Set(), RUNCACHE={}, SMOOTH=0.6, LOGX=false;
const RUNCOLORS=["#58a6ff","#3ec97a","#e6b455","#e25c5c","#bc8cff","#39c5cf","#f778ba","#a5d6ff"];
const api=async(p,o)=>{const r=await fetch(p,o);if(!r.ok)throw new Error(`${p}: ${r.status}`);return r.json()};
const n=(v,d=3)=>v==null?"–":(typeof v==="number"?(Number.isInteger(v)?v:v.toFixed(d)):v);
const pct=v=>v==null?"–":(v*100).toFixed(0)+"%";
const short=m=>String(m).split("/").pop();
const esc=s=>String(s??"").replace(/[<>&"]/g,c=>({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;"}[c]));

// Heat for the aggregate matrices: one hue, alpha carries the value. Two hues would imply a midpoint
// that pass-rate does not have.
const heatBg=p=>p==null?"transparent":`rgba(62,201,122,${(0.08+0.5*p).toFixed(3)})`;
// Only fields that can actually partition the rows are offered: a key with one value filters nothing,
// and one with a value per task (an id, a free-text question) is a list, not a filter.
function metaFacets(tasks){
  const counts={};
  tasks.forEach(t=>Object.entries(t.meta||{}).forEach(([k,v])=>{
    if(v==null||typeof v==="object")return;
    counts[k]=counts[k]||{};
    counts[k][String(v)]=(counts[k][String(v)]||0)+1;
  }));
  const out={};
  for(const [k,vals] of Object.entries(counts)){
    const nv=Object.keys(vals).length;
    if(nv>1 && nv<=12 && nv<tasks.length) out[k]=vals;
  }
  return out;
}
function passesFilters(t){
  return Object.entries(FILT).every(([k,set])=>
    !set.size || set.has(String((t.meta||{})[k])));
}
const heatOf=(pass,measured)=>measured?pass/measured:null;

async function loadTree(){
  TREE=await api("/api/projects");
  const h=await api("/healthz");
  $("#src").textContent=h.source;
  $("#proj").innerHTML=TREE.map(p=>`<option value="${p.project_id}">${esc(p.label||p.project_id)}</option>`).join("");
  renderRuns();
  const f=document.querySelector(".kid"); if(f&&!SEL) f.click();
}

// One list, in the sidebar, scoped to the selected project. Two dropdowns made the project choice and
// the run choice look like peers when the second is nested inside the first.
function syncBoxes(){
  document.querySelectorAll(".kid input[data-run]").forEach(cb=>cb.checked=RUNSEL.has(cb.dataset.run));
}

function renderRuns(){
  const p=TREE.find(x=>x.project_id===$("#proj").value)||TREE[0]||{};
  const ds=(p.datasets||[]), rs=(p.runs||[]);
  $("#tree").innerHTML=`<div class="pgroup">Runs</div>
    ${ds.length?ds.map(d=>`<div class="kid" data-p="${p.project_id}" data-d="${d}"
        title="${esc(d)}">▦ ${esc(d)}</div>`).join(""):""}
    ${rs.length?`<div class="pgroup">Training</div>`+rs.map(r=>`<div class="kid" data-p="${p.project_id}" data-r="${r}"
        title="${esc(r)}"><input type="checkbox" data-run="${esc(r)}"${RUNSEL.has(r)?" checked":""}>${esc(r)}</div>`).join(""):""}
    ${!ds.length&&!rs.length?`<div class="empty-msg" style="padding:18px;font-size:11px">nothing here yet</div>`:""}`;
  document.querySelectorAll(".kid").forEach(el=>el.onclick=(ev)=>{
    if(ev.target.tagName==="INPUT")return;   // the checkbox drives comparison, not navigation
    document.querySelectorAll(".kid").forEach(k=>k.classList.remove("sel"));el.classList.add("sel");
    if(window.matchMedia("(max-width:820px)").matches) document.body.classList.remove("nav-open");
    if(el.dataset.d){RUNSEL.clear();openDataset(el.dataset.p,el.dataset.d)}
    else{RUNSEL.clear();RUNSEL.add(el.dataset.r);syncBoxes();openRuns(el.dataset.p)}
  });
  document.querySelectorAll(".kid input[data-run]").forEach(cb=>cb.onchange=()=>{
    cb.checked?RUNSEL.add(cb.dataset.run):RUNSEL.delete(cb.dataset.run);
    if(RUNSEL.size) openRuns($("#proj").value);
  });
}
$("#proj").onchange=()=>{renderRuns();const f=document.querySelector(".kid");if(f)f.click()};
$("#burger").onclick=()=>document.body.classList.toggle("nav-open");

async function openDataset(pid,did){
  SEL={pid,did};$("#body").innerHTML=`<div class="empty-msg">loading ${did}…</div>`;
  try{DS=await api(`/api/projects/${pid}/datasets/${did}`)}
  catch(e){$("#body").innerHTML=`<div class="empty-msg">${e.message}</div>`;return}
  KMAX=DS.k_max||4; $("#proj").value=pid;
  renderGrid();
}

function renderGrid(){
  const d=DS, models=d.models||[], harnesses=d.harnesses||[];
  const cols = AXIS==="harness"?harnesses:models;
  const subs = AXIS==="harness"?models:harnesses;
  const keyFor=(c,s)=>AXIS==="harness"?`${s}|${c}`:`${c}|${s}`;
  const s=d.summary||{};

  const facets=metaFacets(d.tasks||[]);
  const shown=(d.tasks||[]).filter(passesFilters);
  const filterBar=Object.keys(facets).length?`<div class="filters">
    ${Object.entries(facets).map(([k,vals])=>`<span class="fkey">${esc(k)}</span>` +
      Object.entries(vals).sort((a,b)=>a[0].localeCompare(b[0],undefined,{numeric:true})).map(([v,c])=>
        `<span class="fchip ${(FILT[k]&&FILT[k].has(v))?"on":""}" data-fk="${esc(k)}" data-fv="${esc(v)}"
           >${esc(v)} <span class="src">${c}</span></span>`).join("")).join("")}
    ${Object.values(FILT).some(x=>x.size)?`<span class="fchip" id="fclear">clear</span>`:""}
    <span class="src">${shown.length}/${(d.tasks||[]).length} tasks</span></div>`:"";

  const stats=[["tasks",n(s.tasks_total)],["solved ≤k",n(s.tasks_any_pass)],
    ["attempts",n(s.attempts_total)],["passed",n(s.attempts_passed)],
    // Beside the pass rates on purpose: an all-infra task is unmeasured, not failed, and folding it in
    // as a zero would read as a real (bad) measurement.
    ["unmeasured",n(s.n_all_infra)],["k",KMAX]]
    .map(([l,v])=>`<div class="stat"><div class="num">${v}</div><div class="lbl">${l}</div></div>`).join("");

  // aggregate matrix: model × harness, cells = pass@k
  const agg={};
  shown.forEach(t=>Object.entries(t.cells||{}).forEach(([k,c])=>{
    const [m,h]=k.split("|"); agg[m]=agg[m]||{}; agg[m][h]=agg[m][h]||{cells:0,pass:0,measured:0};
    const a=agg[m][h]; a.cells++;
    const graded=(c.attempts||[]).filter(x=>x.reward!=null);
    if(graded.length){a.measured++; if(c.passed_at)a.pass++}
  }));
  const mxh=`<table><thead><tr><th></th>${harnesses.map(h=>`<th>${esc(h)}</th>`).join("")}</tr></thead><tbody>
    ${models.map(m=>`<tr><td class="lbl">${esc(short(m))}</td>${harnesses.map(h=>{
      const a=(agg[m]||{})[h];
      if(!a||!a.measured) return `<td class="empty">—</td>`;
      const p=a.pass/a.measured;
      return `<td class="cell" style="background:${heatBg(p)}"><b>${pct(p)}</b><div class="det">${a.pass}/${a.measured}</div></td>`;
    }).join("")}</tr>`).join("")}</tbody></table>`;

  // Second matrix pivots on whichever metadata field has the most values — chosen from the data
  // rather than hardcoded to difficulty, because that field is not guaranteed to exist.
  const facetKey=Object.entries(facets).sort((a,b)=>Object.keys(b[1]).length-Object.keys(a[1]).length)[0]?.[0];
  let mxd="";
  if(facetKey){
    const vals=[...new Set(shown.map(t=>String((t.meta||{})[facetKey])).filter(v=>v!=="undefined"))]
      .sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
    const acc={};
    shown.forEach(t=>Object.entries(t.cells||{}).forEach(([k,c])=>{
      const h=k.split("|")[1], v=String((t.meta||{})[facetKey]); if(v==="undefined")return;
      acc[h]=acc[h]||{}; acc[h][v]=acc[h][v]||{measured:0,pass:0};
      if((c.attempts||[]).some(x=>x.reward!=null)){acc[h][v].measured++; if(c.passed_at)acc[h][v].pass++}
    }));
    mxd=`<table><thead><tr><th></th>${vals.map(v=>`<th>${esc(v)}</th>`).join("")}</tr></thead><tbody>
      ${harnesses.map(h=>`<tr><td class="lbl">${esc(h)}</td>${vals.map(v=>{
        const a=(acc[h]||{})[v];
        if(!a||!a.measured)return `<td class="empty">—</td>`;
        const pr=heatOf(a.pass,a.measured);
        return `<td class="cell" style="background:${heatBg(pr)}"><b>${pct(pr)}</b><div class="det">${a.pass}/${a.measured}</div></td>`;
      }).join("")}</tr>`).join("")}</tbody></table>`;
  }

  // the per-task heatmap
  const head=`<tr><th class="col-task">task</th>${cols.flatMap(c=>
    (subs.length?subs:[null]).map(sb=>`<th title="${esc(c)}${sb?" · "+esc(sb):""}">${esc(subs.length>1?short(sb):c)}</th>`)).join("")}</tr>`
    + (subs.length>1?`<tr><th class="col-task"></th>${cols.flatMap(c=>subs.map(()=>`<th>${esc(c)}</th>`)).join("")}</tr>`:"");
  const rows=shown.map(t=>{
    const tds=cols.flatMap(c=>(subs.length?subs:[null]).map(sb=>{
      const cell=(t.cells||{})[keyFor(c,sb)];
      if(!cell) return `<td class="cell empty">—</td>`;
      const graded=(cell.attempts||[]).filter(x=>x.reward!=null);
      // pass-N shades by WHICH attempt passed: darker = solved sooner, so the grid shows reliability
      // rather than a flat pass/fail.
      const cls=cell.passed_at?`pass-${Math.min(cell.passed_at,4)}`:graded.length?"fail":"na";
      const mark=cell.passed_at?`✓${cell.passed_at}`:graded.length?"✗":"·";
      return `<td class="cell ${cls}" data-t="${esc(t.id)}" data-k="${esc(keyFor(c,sb))}"
               title="${graded.length}/${(cell.attempts||[]).length} graded">${mark}</td>`;
    })).join("");
    // Whichever facet is active labels the row, so the pill reflects the data instead of assuming a
    // difficulty scale exists.
    const lbl=facetKey?String((t.meta||{})[facetKey]??""):"";
    return `<tr><td class="col-task" data-task="${esc(t.id)}">
        <div>${lbl?`<span class="diff ${diffLike(facetKey,lbl)}">${esc(lbl)}</span>`:""}<span class="tid">${esc(t.id)}</span></div>
        <div class="q">${esc((t.question||"").slice(0,150))}</div></td>${tds}</tr>`;
  }).join("");

  // Collapsed by default: the grid is what you came for, and six counters above it push it below the
  // fold on a laptop.
  const statSummary=`${n(s.tasks_total)} tasks · ${n(s.tasks_any_pass)} solved ≤k · ${n(s.attempts_total)} attempts`
    + (s.n_all_infra?` · ${n(s.n_all_infra)} unmeasured`:"");
  $("#body").innerHTML=`${filterBar}
    <details class="statwrap"><summary>${statSummary}</summary><div class="stats">${stats}</div></details>
    <div class="matrices">
      <div class="matrix-card"><h4>model × harness <span class="sub">pass@${KMAX}</span></h4>${mxh}</div>
      ${mxd?`<div class="matrix-card"><h4>harness × ${esc(facetKey)} <span class="sub">pass@${KMAX}</span></h4>${mxd}</div>`:""}
    </div>
    <div class="legend">
      <span><i style="background:rgba(62,201,122,.55)"></i>passed 1st</span>
      <span><i style="background:rgba(62,201,122,.18)"></i>passed 4th</span>
      <span><i style="background:rgba(226,92,92,.25)"></i>graded, failed</span>
      <span><i style="background:rgba(230,180,85,.16)"></i>unmeasured (infra)</span>
      <span class="src">click a cell for attempts + trace</span>
    </div>
    <div class="hwrap"><table class="heatmap"><thead>${head}</thead><tbody>${rows}</tbody></table></div>`;
  document.querySelectorAll("td.cell[data-t]").forEach(td=>td.onclick=()=>openCell(td.dataset.t,td.dataset.k));
  document.querySelectorAll("td.col-task[data-task]").forEach(td=>td.onclick=()=>openTask(td.dataset.task));
  document.querySelectorAll(".fchip[data-fk]").forEach(el=>el.onclick=()=>{
    const k=el.dataset.fk,v=el.dataset.fv;
    FILT[k]=FILT[k]||new Set();
    FILT[k].has(v)?FILT[k].delete(v):FILT[k].add(v);
    renderGrid();
  });
  const fc=$("#fclear"); if(fc) fc.onclick=()=>{FILT={};renderGrid()};
}

function diffLike(key,val){
  // Colour a facet value only when it plausibly orders low->high; otherwise leave it neutral rather
  // than implying a ranking the field may not have.
  const num=Number(val);
  if(!/level|difficulty|tier|rank/i.test(key||"")||Number.isNaN(num))return "";
  return num<=0?"easy":num<=2?"medium":"hard";
}

function openTask(taskId){
  const t=(DS.tasks||[]).find(x=>String(x.id)===String(taskId))||{};
  const cells=Object.entries(t.cells||{});
  $("#dtitle").textContent=t.id||taskId;
  $("#dpills").innerHTML=`<span class="pill">${cells.length} cell(s)</span>`;
  const meta=Object.entries(t.meta||{}).map(([k,v])=>
    `<div><em>${esc(k)}</em>${esc(typeof v==="object"?JSON.stringify(v):v)}</div>`).join("");
  const perCell=cells.map(([k,c])=>{
    const [m,h]=k.split("|");
    const graded=(c.attempts||[]).filter(a=>a.reward!=null);
    return `<tr><td>${esc(h)}</td><td>${esc(short(m))}</td>
      <td class="reward ${c.passed_at?"pass":graded.length?"fail":"na"}">
        ${c.passed_at?`✓ attempt ${c.passed_at}`:graded.length?"✗ failed":"unmeasured"}</td>
      <td>${graded.length}/${(c.attempts||[]).length}</td>
      <td>${c.attempts?.[0]?.n_turns??"–"}</td>
      <td><button data-cell="${esc(k)}">open</button></td></tr>`;
  }).join("");
  $("#dbody").innerHTML=`
    <div class="taskcard"><div class="msg-head" style="background:none;padding:0 0 4px">question</div>
      <pre class="msg-body" style="padding:0">${esc(t.question||"(none recorded)")}</pre></div>
    ${t.answer!=null?`<div class="taskcard"><div class="msg-head" style="background:none;padding:0 0 4px">gold answer</div>
      <pre class="msg-body gold" style="padding:0">${esc(t.answer)}</pre></div>`:""}
    ${meta?`<div class="taskcard"><div class="msg-head" style="background:none;padding:0">metadata</div>
      <div class="metagrid">${meta}</div></div>`:""}
    <table class="trial-table"><thead><tr><th>harness</th><th>model</th><th>result</th><th>graded</th><th>turns</th><th></th></tr></thead>
      <tbody>${perCell}</tbody></table>`;
  $("#dlg").showModal();
  document.querySelectorAll("#dbody button[data-cell]").forEach(b=>
    b.onclick=()=>openCell(taskId,b.dataset.cell));
}

async function openCell(taskId,cellKey){
  const t=(DS.tasks||[]).find(x=>String(x.id)===String(taskId))||{};
  const cell=(t.cells||{})[cellKey]||{};
  const [model,harness]=cellKey.split("|");
  const graded=(cell.attempts||[]).filter(a=>a.reward!=null);
  const pass1=graded.length?graded.filter(a=>a.reward>0).length/graded.length:null;
  $("#dtitle").textContent=`${taskId}`;
  $("#dpills").innerHTML=`<span class="pill">${esc(harness)}</span> <span class="pill">${esc(short(model))}</span>
    <span class="pill ${cell.passed_at?"ok":"exp"}">${cell.passed_at?`pass@${KMAX} ✓ (attempt ${cell.passed_at})`:graded.length?"failed":"unmeasured"}</span>`;
  $("#dbody").innerHTML=`
    <div class="stats" style="padding:0 0 8px">
      <div class="stat"><div class="num">${pass1==null?"–":pct(pass1)}</div><div class="lbl">pass@1</div></div>
      <div class="stat"><div class="num">${graded.length}/${(cell.attempts||[]).length}</div><div class="lbl">graded</div></div>
      <div class="stat"><div class="num">${cell.passed_at??"–"}</div><div class="lbl">first pass</div></div>
    </div>
    ${t.question?`<div class="msg"><div class="msg-head">question</div><pre class="msg-body">${esc(t.question)}</pre></div>`:""}
    ${t.answer!=null?`<div class="msg"><div class="msg-head">gold</div><pre class="msg-body gold">${esc(t.answer)}</pre></div>`:""}
    <table class="trial-table"><thead><tr><th>#</th><th>reward</th><th>turns</th><th>elapsed</th><th></th></tr></thead>
      <tbody>${(cell.attempts||[]).map(a=>`<tr>
        <td>${a.attempt}</td>
        <td class="reward ${a.reward==null?"na":a.reward>0?"pass":"fail"}">${a.reward==null?"unmeasured":n(a.reward,2)}</td>
        <td>${n(a.n_turns)}</td><td>${a.elapsed_sec?n(a.elapsed_sec,1)+"s":"–"}</td>
        <td>${a.trace?`<button data-tr="${esc(a.trace)}">trace</button>`:`<span class="src">no trace recorded</span>`}</td>
      </tr>`).join("")}</tbody></table>
    <div id="tr"></div>`;
  $("#dlg").showModal();
  document.querySelectorAll("#dbody button[data-tr]").forEach(b=>b.onclick=()=>showTrace(b.dataset.tr));
}

async function showTrace(path){
  $("#tr").innerHTML=`<div class="empty-msg">loading…</div>`;
  let tr;
  try{tr=await api(`/api/projects/${SEL.pid}/datasets/${SEL.did}/trace?path=${encodeURIComponent(path)}`)}
  catch(e){$("#tr").innerHTML=`<div class="empty-msg">${e.message}</div>`;return}
  // reward_method is surfaced because the grader is exact -> numeric -> LLM judge: a 0 from the judge is
  // a wrong answer, a 0 from exact match may only be a formatting mismatch.
  const meta=["reward","reward_method","n_turns","elapsed_sec","gold","predicted"].filter(k=>tr[k]!==undefined)
    .map(k=>`<div class="stat"><div class="num">${esc(n(tr[k],2))}</div><div class="lbl">${k}</div></div>`).join("");
  const msgs=(tr.messages||[]).map(m=>{
    const calls=(m.tool_calls||[]).map(c=>JSON.stringify(c)).join("\n");
    const body=(typeof m.content==="string"?m.content:JSON.stringify(m.content,null,2))||"";
    return `<div class="msg"><div class="msg-head">${esc(m.role)}${calls?" · tool_calls":""}</div>
      <pre class="msg-body">${esc(body)}${calls?"\n\n"+esc(calls):""}</pre></div>`;
  }).join("");
  $("#tr").innerHTML=`<div class="stats" style="padding:10px 0 6px">${meta}</div>
    ${msgs||`<pre class="msg-body">${esc(JSON.stringify(tr,null,2).slice(0,4000))}</pre>`}`;
}

async function openRuns(pid){
  const ids=[...RUNSEL];
  if(!ids.length){$("#body").innerHTML=`<div class="empty-msg">select a training run</div>`;return}
  SEL={pid,rid:ids[0],runs:ids};
  $("#body").innerHTML=`<div class="empty-msg">loading ${ids.length} run(s)…</div>`;

  const loaded=[];
  for(const rid of ids){
    const key=`${pid}/${rid}`;
    if(!RUNCACHE[key]){
      let meta={},d={steps:[]};
      try{meta=await api(`/api/projects/${pid}/runs/${rid}`)}catch(e){}
      try{d=await api(`/api/projects/${pid}/runs/${rid}/train`)}
      catch(e){RUNCACHE[key]={rid,meta,steps:[],error:e.message};loaded.push(RUNCACHE[key]);continue}
      RUNCACHE[key]={rid,meta,steps:d.steps||[],skipped:d.skipped_lines,rollouts:d.rollout_steps||[]};
    }
    loaded.push(RUNCACHE[key]);
  }
  renderTrain(loaded);
}

function renderTrain(runs){
  // Chart every numeric series the runs actually logged, rather than a fixed list: a trainer that adds
  // a metric should show it without a code change here, the same reason task metadata is discovered.
  const keys=[...new Set(runs.flatMap(r=>r.steps.flatMap(s=>
    Object.entries(s).filter(([k,v])=>typeof v==="number"&&k!=="step").map(([k])=>k))))];
  const preferred=["reward","reward_std","loss","ratio","kl","entropy","learning_rate"];
  keys.sort((a,b)=>{
    const ia=preferred.indexOf(a),ib=preferred.indexOf(b);
    return (ia<0?99:ia)-(ib<0?99:ib) || a.localeCompare(b);
  });

  const summary=runs.map((r,i)=>{
    const g=r.steps.filter(s=>(s.reward_std||0)>0).length;
    return `<div class="stat" style="border-left:3px solid ${RUNCOLORS[i%RUNCOLORS.length]}">
      <div class="num">${r.steps.length}<span class="src"> steps</span></div>
      <div class="lbl">${esc(r.rid)}</div>
      <div class="lbl">${g}/${r.steps.length} with a gradient${r.error?" · "+esc(r.error):""}</div></div>`;
  }).join("");

  const legend=runs.map((r,i)=>
    `<span><i style="background:${RUNCOLORS[i%RUNCOLORS.length]}"></i>${esc(r.rid)}</span>`).join("");

  const charts=keys.map(k=>{
    const series=runs.map((r,i)=>({
      pts:r.steps.map((s,j)=>[s.step??j,s[k]]).filter(pt=>typeof pt[1]==="number"),
      color:RUNCOLORS[i%RUNCOLORS.length],
    })).filter(x=>x.pts.length);
    if(!series.length)return "";
    return `<div class="chartbox"><h5>${esc(k)}<span>${series.map(x=>x.pts.length).join(" / ")} pts</span></h5>
      ${multiChart(series)}</div>`;
  }).join("");

  const anyRollouts=runs.some(r=>(r.rollouts||[]).length);
  $("#body").innerHTML=`
    <div class="ctrls">
      <label>smoothing <input type="range" id="sm" min="0" max="0.95" step="0.05" value="${SMOOTH}">
        <span class="src" id="smv">${SMOOTH.toFixed(2)}</span></label>
      <label><input type="checkbox" id="logx"${LOGX?" checked":""}> log x</label>
      <span class="src">${runs.length} run(s) overlaid — tick more in the sidebar to compare</span>
      ${anyRollouts?`<button id="rollbtn">rollout traces</button>`:`<span class="src">no rollout traces recorded for these runs</span>`}
    </div>
    <details class="statwrap" open><summary>${runs.length} run(s)</summary><div class="stats">${summary}</div></details>
    <div class="rlegend">${legend}</div>
    <div class="chartgrid">${charts||`<div class="empty-msg">no numeric series logged</div>`}</div>`;

  $("#sm").oninput=(e)=>{SMOOTH=+e.target.value;$("#smv").textContent=SMOOTH.toFixed(2);renderTrain(runs)};
  $("#logx").onchange=(e)=>{LOGX=e.target.checked;renderTrain(runs)};
  const rb=$("#rollbtn"); if(rb) rb.onclick=()=>openRollouts(runs);
}

// EMA, applied left to right. Shown WITH the raw series rather than instead of it: a smoothed curve
// hides the variance, and on a GRPO run the variance is the signal — a flat reward with high spread
// means different things from a flat reward with none.
function ema(pts,alpha){
  if(alpha<=0)return pts;
  const out=[];let acc=null;
  for(const [x,y] of pts){acc = acc===null ? y : alpha*acc+(1-alpha)*y; out.push([x,acc])}
  return out;
}

function multiChart(series){
  const W=330,H=140,P=34,B=20;
  const all=series.flatMap(s=>s.pts);
  const xs=all.map(p=>p[0]),ys=all.map(p=>p[1]);
  let x0=Math.min(...xs),x1=Math.max(...xs);if(x0===x1)x1=x0+1;
  let y0=Math.min(...ys),y1=Math.max(...ys);if(y0===y1){y0-=.5;y1+=.5}
  const pad=(y1-y0)*0.08;y0-=pad;y1+=pad;
  const fx=v=>LOGX?Math.log10(Math.max(v,1)):v;
  const fx0=fx(x0),fx1=Math.max(fx(x1),fx0+1e-9);
  const sx=v=>P+(fx(v)-fx0)/(fx1-fx0)*(W-P-8);
  const sy=v=>H-B-(v-y0)/(y1-y0)*(H-B-14);
  const grid=[0,.5,1].map(f=>{const y=y0+(y1-y0)*f;
    return `<line x1="${P}" x2="${W-8}" y1="${sy(y).toFixed(1)}" y2="${sy(y).toFixed(1)}" stroke="#30363d" stroke-width=".5"/>
            <text x="2" y="${(sy(y)+3).toFixed(1)}" fill="#6e7681" font-size="8.5">${y.toFixed(2)}</text>`}).join("");
  const paths=series.map(s=>{
    const raw=s.pts.map((p,i)=>`${i?"L":"M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join("");
    const sm=ema(s.pts,SMOOTH).map((p,i)=>`${i?"L":"M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join("");
    return `<path d="${raw}" fill="none" stroke="${s.color}" stroke-width="1" opacity="${SMOOTH>0?0.22:1}"/>
            ${SMOOTH>0?`<path d="${sm}" fill="none" stroke="${s.color}" stroke-width="1.8"/>`:""}`;
  }).join("");
  return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">${grid}${paths}
    <text x="${P}" y="${H-4}" fill="#6e7681" font-size="8.5">${x0}</text>
    <text x="${W-30}" y="${H-4}" fill="#6e7681" font-size="8.5">${x1}</text></svg>`;
}

async function openRollouts(runs){
  const withR=runs.filter(r=>(r.rollouts||[]).length);
  $("#dtitle").textContent="rollout traces";
  $("#dpills").innerHTML=`<span class="pill">${withR.length} run(s)</span>`;
  $("#dbody").innerHTML=withR.map(r=>`<div class="taskcard"><b>${esc(r.rid)}</b>
    <table class="trial-table"><thead><tr><th>step</th><th>rollouts</th><th>rewards</th><th></th></tr></thead>
    <tbody>${r.rollouts.map(st=>`<tr><td>${st.step}</td><td>${st.n}</td>
      <td>${(st.rewards||[]).map(x=>x==null?"·":Number(x).toFixed(2)).join(" ")}</td>
      <td><button data-run="${esc(r.rid)}" data-step="${st.step}">open</button></td></tr>`).join("")}
    </tbody></table></div>`).join("") || `<div class="empty-msg">no rollout traces recorded</div>`;
  $("#dlg").showModal();
  document.querySelectorAll("#dbody button[data-step]").forEach(b=>b.onclick=()=>showRolloutStep(b.dataset.run,b.dataset.step));
}

async function showRolloutStep(rid,step){
  const box=$("#dbody");
  box.insertAdjacentHTML("afterbegin",`<div id="rs" class="empty-msg">loading step ${step}…</div>`);
  let rows;
  try{rows=await api(`/api/projects/${SEL.pid}/runs/${rid}/rollouts/${step}`)}
  catch(e){$("#rs").outerHTML=`<div class="warnbox">${e.message}</div>`;return}
  $("#rs").outerHTML=`<div class="taskcard"><b>step ${esc(step)} · ${rows.rollouts.length} rollout(s)</b>
    ${rows.rollouts.map((ro,i)=>`
      <details><summary class="src">#${i+1} · reward ${ro.reward==null?"unmeasured":n(ro.reward,2)}
        · ${n(ro.n_turns)} turns · ${esc(ro.task_id||"")}</summary>
        ${(ro.messages||[]).map(m=>`<div class="msg"><div class="msg-head">${esc(m.role)}</div>
          <pre class="msg-body">${esc(typeof m.content==="string"?m.content:JSON.stringify(m.content,null,2))}</pre></div>`).join("")
          || `<div class="src" style="padding:6px">no conversation recorded for this rollout</div>`}
      </details>`).join("")}</div>`;
}

function chart(steps,key){
  const pts=steps.map((s,i)=>[s.step??i,s[key]]).filter(p=>typeof p[1]==="number");
  if(!pts.length)return"";
  const W=380,H=112,P=30,xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]);
  const x0=Math.min(...xs),x1=Math.max(...xs,x0+1);let y0=Math.min(...ys),y1=Math.max(...ys);
  if(y0===y1){y0-=.5;y1+=.5}
  const sx=v=>P+(v-x0)/(x1-x0)*(W-P-8),sy=v=>H-P+4-(v-y0)/(y1-y0)*(H-P-12);
  return `<svg width="${W}" height="${H}" style="margin:0 8px 8px 0">
    <text x="6" y="13" fill="#8b949e" font-size="10.5">${key}</text>
    <text x="6" y="${H-6}" fill="#6e7681" font-size="9">${y0.toFixed(2)}</text>
    <text x="${W-44}" y="${H-6}" fill="#6e7681" font-size="9">step ${x1}</text>
    <path d="${pts.map((p,i)=>`${i?"L":"M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join("")}"
      fill="none" stroke="#58a6ff" stroke-width="1.5"/></svg>`;
}
$("#axH").onclick=()=>{AXIS="harness";$("#axH").classList.add("on");$("#axM").classList.remove("on");if(DS&&SEL?.did)renderGrid()};
$("#axM").onclick=()=>{AXIS="model";$("#axM").classList.add("on");$("#axH").classList.remove("on");if(DS&&SEL?.did)renderGrid()};
$("#refresh").onclick=async()=>{$("#refresh").textContent="…";
  try{await api("/api/refresh",{method:"POST"});RUNCACHE={};const k=SEL;await loadTree();
      if(k?.did)await openDataset(k.pid,k.did);else if(RUNSEL.size)await openRuns(k.pid)}
  finally{$("#refresh").textContent="⟳"}};
loadTree();
</script>