@wendellresearch-ai/benchwarmers 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.4
1
+ 0.6.6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
package/serve.py CHANGED
@@ -2150,9 +2150,10 @@ def first_run() -> str:
2150
2150
  text = ("Pick a model your endpoints serve. Onboarding registers it, probes it (4 calls) and runs"
2151
2151
  " the internal suite, so it takes minutes and spends calls.")
2152
2152
  elif not bench_collections.load():
2153
- step, head, label = "collection", "Make your first collection", "New collection"
2154
- text = ("The models a panel always asks, and what it is for. Callers then ask for"
2155
- f" bench/<name> through the gateway at {esc(gateway_url())}.")
2153
+ step, head, label = "collection", "Make your first collection", "Make bench/core"
2154
+ text = ("The models a panel always asks, and what it is for. It opens filled in as bench/core, the"
2155
+ " name the set-ups on Clients use, with the models you onboarded ticked: change anything,"
2156
+ f" then save. Callers ask for it through the gateway at {esc(gateway_url())}.")
2156
2157
  else:
2157
2158
  return ""
2158
2159
  return (f"<section class='problems' id='first-run' data-step='{step}' aria-labelledby='first-head'>"
@@ -2701,7 +2702,14 @@ def vision_card() -> str:
2701
2702
  come from these alone. <b>Useful, no claims</b> is a verdict without them, which on thicken's
2702
2703
  photos means the seat agreed with another seat. Agreement is not a check, since seats fail
2703
2704
  together, so it is never counted as right.</dd>
2704
- <dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks, seats on real work.</dd>
2705
+ <dt>The real-work chart</dt><dd>Read as Real work is. The dot is the share of its judged seats your
2706
+ verdict called useful (checked and helped, or useful with no claims), bigger for more judged; the
2707
+ line is where that share likely sits (95%), and the order is by its low end, so a lot of evidence
2708
+ outranks a little. Green when even the low end is over half, red when even the high end is under,
2709
+ grey when there are too few to say. Beside it: useful of judged, how often it answered at all, and
2710
+ the claims checked by hand, the only count of right and wrong. Seats not judged or not answered
2711
+ are in those words and the table, not the dot.</dd>
2712
+ <dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks.</dd>
2705
2713
  </dl>"""
2706
2714
  held = (f"<p class='hint'>The vision tasks wait until {esc(v['held'])}: no run picks them before then"
2707
2715
  " (<code>vision_from</code> in <code>config/suite.json</code>; remove it to start them now).</p>"
@@ -2709,7 +2717,7 @@ def vision_card() -> str:
2709
2717
  return (f"<div class='panel'>{SHARE}<h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
2710
2718
  " a known answer, then real work on images and how much of it was actually checked.</p>" + held +
2711
2719
  "<h3 class='chart-head'>On the vision tasks, field by field</h3><div class='chart' id='chart-vision-fields'></div>"
2712
- "<h3 class='chart-head'>On real work with images, by how each seat was judged</h3>"
2720
+ "<h3 class='chart-head'>On real work with images: judged useful, and how sure that is</h3>"
2713
2721
  "<div class='chart' id='chart-vision-work'></div>"
2714
2722
  "<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
2715
2723
  " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
@@ -3224,7 +3232,7 @@ def uptime_data() -> dict:
3224
3232
 
3225
3233
  def uptime_card() -> str:
3226
3234
  """Was the provider there when the bench called it: a cell a day, by
3227
- provider then by model; 7 days, or 30 on choice (bench.js swaps them)."""
3235
+ provider or by model, as its By says; 7 days, or 30 on choice (bench.js swaps them)."""
3228
3236
  u = uptime_rows()
3229
3237
  first, gone, names = UPTIME_DAYS[0], retired(), {}
3230
3238
 
@@ -3298,9 +3306,11 @@ def uptime_card() -> str:
3298
3306
  "<span><i style='background:var(--signal)'></i>some down, 80% or more answered</span>"
3299
3307
  "<span><i style='background:var(--err)'></i>under 80% answered</span>"
3300
3308
  "<span><i style='background:none;border:1px solid var(--line)'></i>no counted calls</span></div>")
3309
+ # The window and one view at a time: by provider (shorter, the default) or by model (bench.js).
3301
3310
  pick = ("<div class='toolbar'><label>Days <select data-uptime-days>"
3302
3311
  + "".join(f"<option value='{w}'>last {w}</option>" for w in UPTIME_DAYS)
3303
- + f"</select></label></div>{legend}")
3312
+ + "</select></label><label>By <select data-uptime-by><option value='providers'>provider</option>"
3313
+ f"<option value='models'>model</option></select></label></div>{legend}")
3304
3314
  key = """<dl>
3305
3315
  <dt>Down</dt><dd>The provider was busy or overloaded (a 5xx, high demand, a shared pool's
3306
3316
  429), unreachable (a broken stream, no reply), timed out (the gateway's 420 s included), or
@@ -3326,9 +3336,7 @@ def uptime_card() -> str:
3326
3336
  " and every real call, one cell a day. Busy, unreachable and timed out count as down;"
3327
3337
  " our own quota, our own pacing and setup problems are left out.",
3328
3338
  "chart-uptime-models", "".join(window(w) for w in UPTIME_DAYS), key,
3329
- extra=pick + "<h3 class='chart-head'>By provider</h3>"
3330
- "<div class='chart' id='chart-uptime-providers'></div>"
3331
- "<h3 class='chart-head'>By model</h3>",
3339
+ extra=pick + "<div class='chart' id='chart-uptime-providers'></div>",
3332
3340
  after="".join(odd(w) for w in UPTIME_DAYS))
3333
3341
 
3334
3342
 
@@ -3655,10 +3663,20 @@ def wrong_card() -> str:
3655
3663
 
3656
3664
  def benchmarks_tab() -> str:
3657
3665
  data = json.dumps(charts_data(), ensure_ascii=False).replace("</", "<\\/")
3658
- # Trend, Uptime and Performance side by side: what moves over time, in one stretch.
3659
- return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() + vision_card()
3660
- + trend_card() + uptime_card() + perf_card() + decision_card() + external_card()
3661
- + f"<script type='application/json' id='charts-data'>{data}</script>")
3666
+ # Who to trust, then what moves day to day (Uptime, Trend, Performance), then Real work,
3667
+ # then the rest in the order they had. Each report gets an id; a bar of links to them
3668
+ # stays at the top while the page scrolls, the one in view marked (bench.js jumpSpy).
3669
+ reports = (("trust", "Who to trust", overview_card), ("uptime", "Uptime", uptime_card),
3670
+ ("trend", "Trend", trend_card), ("performance", "Performance", perf_card),
3671
+ ("work", "Real work", work_card), ("hunt", "Bug hunt", bughunt_card),
3672
+ ("wrong", "Confidently wrong", wrong_card), ("code", "Our code", suite_card),
3673
+ ("vision", "Vision", vision_card), ("decisions", "Decision models", decision_card),
3674
+ ("external", "External", external_card))
3675
+ links = "".join(f"<a href='#report-{key}'>{esc(label)}</a>" for key, label, _ in reports)
3676
+ cards = "".join(card().replace("<div class='panel'", f"<div class='panel report' id='report-{key}'", 1)
3677
+ for key, _, card in reports)
3678
+ return (f"<nav class='jump' aria-label='On this page'><p class='jump-title'>On this page</p>{links}</nav>{cards}"
3679
+ f"<script type='application/json' id='charts-data'>{data}</script>")
3662
3680
 
3663
3681
 
3664
3682
  def perf_card() -> str:
@@ -5251,11 +5269,16 @@ def models_page() -> str:
5251
5269
  " comes from, with the endpoint it is reached through and what is known of its limits. A name"
5252
5270
  " opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
5253
5271
  " placeholder='Find a model or connection' aria-label='Filter models'>"
5254
- # One labelled toggle, as accordions do it (GOV.UK's "Show all sections"): Collapse all
5255
- # while every table is open, else Expand all (bench.js foldButtons keeps it right).
5256
- "<button class='fold-all' type='button' data-fold-all='close' aria-expanded='true'>"
5257
- "<span class='fold-chev' aria-hidden='true'></span>Collapse all</button></div></div>"
5258
- + "".join(cards))
5272
+ f"{fold_toggle(True)}</div></div>" + "".join(cards))
5273
+
5274
+
5275
+ def fold_toggle(all_open: bool) -> str:
5276
+ """One labelled toggle over a page's folds (Models' connections, Logs' logs), as
5277
+ accordions do it (GOV.UK's "Show all sections"): Collapse all while every one is
5278
+ open, else Expand all. bench.js foldButtons keeps it right as folds change."""
5279
+ return (f"<button class='fold-all' type='button' data-fold-all='{'close' if all_open else 'open'}'"
5280
+ f" aria-expanded='{str(all_open).lower()}'><span class='fold-chev' aria-hidden='true'></span>"
5281
+ f"{'Collapse all' if all_open else 'Expand all'}</button>")
5259
5282
 
5260
5283
 
5261
5284
  def quota_tiles() -> list[dict]:
@@ -5431,16 +5454,25 @@ def logs_page() -> str:
5431
5454
  for p in sorted((DATA / "runs").glob("onboard-*.log"), reverse=True)[:30])
5432
5455
 
5433
5456
  def section(title, body, n, open_=False):
5434
- # CSV: this log alone, its own columns, the filter applied (bench.js); for a spreadsheet.
5435
5457
  return (f"<details class='panel'{' open' if open_ else ''} data-log='{esc(title)}'><summary><b>{esc(title)}</b>"
5436
- f" <span class='muted'>· {n}</span> <button class='btn small' type='button' data-csv"
5437
- f" title='Save this log, as filtered, as a CSV file'>CSV</button></summary>{body}</details>")
5458
+ f" <span class='muted'>· {n}</span></summary>{body}</details>")
5459
+ # Export: a popup for the format and the logs (bench.js), each as the page shows it,
5460
+ # the filter applied. Text, every log chosen in one file to read; CSV, a file per log.
5461
+ picks = "".join(f"<label class='check'><input type='checkbox' name='lx-log' value='{esc(t)}' checked> {esc(t)}</label>"
5462
+ for t in ("Gateway calls", "Changes", "Scheduler", "Onboarding runs"))
5463
+ export = ("<dialog id='logs-export-dialog' aria-labelledby='lx-title'><h2 id='lx-title'>Export logs</h2>"
5464
+ "<fieldset><legend class='field'>Format</legend>"
5465
+ "<label class='check'><input type='radio' name='lx-format' value='txt' checked> Text (.txt)</label>"
5466
+ "<label class='check'><input type='radio' name='lx-format' value='csv'> CSV (.csv)</label></fieldset>"
5467
+ f"<fieldset><legend class='field'>Logs</legend>{picks}</fieldset>"
5468
+ "<p class='rules' id='lx-note'></p><div class='errors' id='lx-errors' role='alert'></div>"
5469
+ "<div class='actions'><button class='btn' type='button' id='lx-cancel'>Cancel</button>"
5470
+ "<button class='btn primary' type='button' id='lx-go'>Export</button></div></dialog>")
5438
5471
  return (
5439
5472
  "<div class='panel'><div class='toolbar'><input data-filter='page' placeholder='Filter every log on this page'"
5440
5473
  " aria-label='Filter logs'>"
5441
- # bench.js: what the page shows, filter applied, as one .txt to read offline.
5442
- "<button class='btn small' type='button' id='logs-export' title='Save the logs on this page, as"
5443
- " filtered, to a text file'>Export as text</button></div></div>"
5474
+ "<button class='btn small' type='button' id='logs-export'>Export…</button>"
5475
+ f"{fold_toggle(False)}</div></div>{export}"
5444
5476
  + section("Gateway calls", "<table><thead><tr>" + th("When") + th("Collection") + th("Shape")
5445
5477
  + th("Seats") + f"</tr></thead><tbody>{''.join(gw)}</tbody></table>", len(gw), True)
5446
5478
  + section("Changes", "<table><thead><tr>" + th("When") + th("By") + th("Model") + th("What")
@@ -5759,7 +5791,9 @@ class Handler(gateway.Handler):
5759
5791
  if self.path == "/api/needs": # after an action there: that page, not the whole one
5760
5792
  with locked.snapshot():
5761
5793
  groups, auto = open_actions()
5762
- self._json(200, {"html": actions_page(groups, auto), "count": open_count(groups)})
5794
+ # `first`: first run's next step now ("" when set up), so a step done goes at once.
5795
+ self._json(200, {"html": actions_page(groups, auto), "count": open_count(groups),
5796
+ "first": first_run()})
5763
5797
  return
5764
5798
  if path == "/api/pane": # a tab's page, when first opened (bench.js load)
5765
5799
  key = q.get("tab", [""])[0]
package/ui/bench.css CHANGED
@@ -536,6 +536,34 @@ pre.log.answer { max-height: 40vh; }
536
536
  .chart.muted { font-size: .875rem; }
537
537
  h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
538
538
  .chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
539
+ /* Benchmarks' "On this page": a link per report, the one in view marked (bench.js jumpSpy),
540
+ after the U.S. Web Design System's in-page navigation. Dressed as the sidebar is: the
541
+ theme's frosted rail, muted links, the highlighter stroke on the current one.
542
+ Narrow: a bar floating at the top of the page, sideways when it does not fit.
543
+ Wide: a rail beside the reports, sticky, every link on its own line. */
544
+ .jump { position: sticky; top: .75rem; z-index: 4; display: flex; align-items: center; gap: 1rem;
545
+ margin: .9rem 1.2rem 0; padding: .45rem 1.1rem; overflow-x: auto; scrollbar-width: none;
546
+ background: var(--rail, var(--paper-2)); border: 1px solid var(--line); border-radius: 999px;
547
+ -webkit-backdrop-filter: var(--glass, blur(10px)); backdrop-filter: var(--glass, blur(10px));
548
+ box-shadow: 0 10px 28px -16px rgb(0 0 0 / .4); }
549
+ .jump::-webkit-scrollbar { display: none; }
550
+ .jump .jump-title { display: none; margin: 0 0 .2rem; font-size: .75rem; font-weight: 700; letter-spacing: .08em;
551
+ text-transform: uppercase; color: color-mix(in oklab, var(--brand) 72%, var(--ink)); }
552
+ .jump a { flex: none; padding: .1rem 0; line-height: 1.15; color: var(--muted); text-decoration: none;
553
+ font-size: .9375rem; font-weight: 500; white-space: nowrap; }
554
+ .jump a:hover, .jump a:focus-visible { color: var(--ink); }
555
+ .jump a[aria-current] { color: var(--ink); font-weight: 700; box-shadow: inset 0 -.42em 0 var(--signal); }
556
+ .panel.report { scroll-margin-top: 4.2rem; } /* a jump lands under the bar, not behind it */
557
+ @media (min-width: 75rem) {
558
+ #tab-suite:has(> .jump) { display: grid; grid-template-columns: minmax(0, 1fr) 11rem; }
559
+ #tab-suite:has(> .jump) > * { grid-column: 1; }
560
+ #tab-suite > .jump { grid-column: 2; grid-row: 1 / span 40; align-self: start; top: 1rem;
561
+ flex-direction: column; align-items: flex-start; gap: .6rem; overflow: visible;
562
+ margin: 1.4rem 1rem 1.4rem 0; padding: 1rem 1.1rem; border-radius: 14px; box-shadow: none; }
563
+ #tab-suite > .jump .jump-title { display: block; }
564
+ #tab-suite > .jump a { white-space: normal; }
565
+ .panel.report { scroll-margin-top: 1rem; }
566
+ }
539
567
  /* Models: each connection a fold, its heading the summary */
540
568
  .model-card > summary h3.chart-head { display: inline; margin: 0; }
541
569
  /* Expand all / Collapse all: a text toggle with a chevron that turns, like a fold's own marker */
package/ui/bench.js CHANGED
@@ -279,8 +279,9 @@
279
279
  // ---- Expand all / Collapse all: every fold of the page's groups (Models' connections).
280
280
  // One labelled toggle, as accordions have it: Collapse all while every one is open,
281
281
  // else Expand all. Kept right after any fold opens or shuts, by hand, by it or by a live swap.
282
+ var FOLDS = 'details.model-card, details[data-log]'; // the groups a page folds: Models' connections, Logs' logs
282
283
  function foldButtons(tab) {
283
- var folds = tab.querySelectorAll('details.model-card'), open = 0;
284
+ var folds = tab.querySelectorAll(FOLDS), open = 0;
284
285
  folds.forEach(function (d) { if (d.open) open++; });
285
286
  var all = open === folds.length;
286
287
  tab.querySelectorAll('[data-fold-all]').forEach(function (b) {
@@ -293,11 +294,11 @@
293
294
  var b = e.target.closest('[data-fold-all]');
294
295
  if (!b) return;
295
296
  var tab = b.closest('.tab');
296
- tab.querySelectorAll('details.model-card').forEach(function (d) { d.open = b.dataset.foldAll === 'open'; });
297
+ tab.querySelectorAll(FOLDS).forEach(function (d) { d.open = b.dataset.foldAll === 'open'; });
297
298
  foldButtons(tab);
298
299
  });
299
300
  document.addEventListener('toggle', function (e) { // toggle does not bubble: caught on its way down
300
- if (e.target.matches && e.target.matches('details.model-card')) foldButtons(e.target.closest('.tab'));
301
+ if (e.target.matches && e.target.matches(FOLDS)) foldButtons(e.target.closest('.tab'));
301
302
  }, true);
302
303
 
303
304
  // ---- Logs: Export as text (every log in one .txt, to read) and CSV (one log, its
@@ -306,42 +307,54 @@
306
307
  var now = new Date(), two = function (n) { return String(n).padStart(2, '0'); };
307
308
  return now.getFullYear() + '-' + two(now.getMonth() + 1) + '-' + two(now.getDate()) + '-' + two(now.getHours()) + two(now.getMinutes());
308
309
  }
309
- // The file itself: Share's saveFile(blob, name) and csv(rows) (RFC 4180, with a BOM).
310
- document.addEventListener('click', function (e) {
311
- var b = e.target.closest('[data-csv]');
312
- if (!b) return;
313
- e.preventDefault(); // in the summary: no fold
314
- var d = b.closest('details'), rows = [];
310
+ // A log as the page shows it (the filter applied): its header and rows, as cells.
311
+ // A log of lines (Scheduler, Onboarding runs) is one column, Line.
312
+ function logRows(d) {
315
313
  var cells = function (tr) {
316
314
  return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); });
317
315
  };
318
- var head = d.querySelector('thead tr');
319
- if (head) {
320
- rows.push(cells(head));
321
- d.querySelectorAll('tbody tr').forEach(function (r) { if (!r.hidden) rows.push(cells(r)); });
322
- } else {
323
- rows.push(['Line']);
324
- d.querySelectorAll('.log-line').forEach(function (r) { if (!r.hidden) rows.push([r.textContent.trim()]); });
325
- }
326
- var slug = d.dataset.log.toLowerCase().replace(/[^a-z0-9]+/g, '-');
327
- saveFile(new Blob([csv(rows)], {type: 'text/csv;charset=utf-8'}), 'benchwarmers-' + slug + '-' + stampNow() + '.csv');
328
- });
316
+ var head = d.querySelector('thead tr'), rows = [head ? cells(head) : ['Line']];
317
+ d.querySelectorAll(head ? 'tbody tr' : '.log-line').forEach(function (r) {
318
+ if (!r.hidden) rows.push(head ? cells(r) : [r.textContent.trim()]);
319
+ });
320
+ return rows;
321
+ }
322
+ // Export…: the popup picks the format and the logs. Text: every log chosen in one
323
+ // .txt to read, tab-separated. CSV: a .csv per log (its own columns), through Share's
324
+ // saveFile(blob, name) and csv(rows) (RFC 4180, with a BOM).
329
325
  on('click', 'logs-export', function () {
330
- var tab = $('tab-logs'), out = ['Benchwarmers logs, exported ' + new Date().toString()];
331
- var q = (tab.querySelector('input[data-filter]') || {}).value;
332
- if (q) out.push('Filtered by: ' + q);
333
- tab.querySelectorAll('details.panel').forEach(function (d) {
334
- out.push('', '== ' + d.dataset.log + ' ==');
335
- var cells = function (tr) {
336
- return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); }).join('\t');
337
- };
338
- var head = d.querySelector('thead tr');
339
- if (head) out.push(cells(head));
340
- d.querySelectorAll('tbody tr, .log-line').forEach(function (r) {
341
- if (!r.hidden) out.push(r.matches('tr') ? cells(r) : r.textContent.trim());
326
+ var q = ($('tab-logs').querySelector('input[data-filter]') || {}).value;
327
+ $('lx-note').textContent = q ? 'Only the lines that match the filter, "' + q + '", as the page shows them.'
328
+ : 'Every line the page shows: the last 200 of each log.';
329
+ $('lx-errors').textContent = '';
330
+ $('logs-export-dialog').showModal();
331
+ });
332
+ on('click', 'lx-cancel', function () { $('logs-export-dialog').close(); });
333
+ on('click', 'lx-go', function () {
334
+ var tab = $('tab-logs'), dlg = $('logs-export-dialog');
335
+ var format = dlg.querySelector('input[name=lx-format]:checked').value;
336
+ var want = Array.prototype.filter.call(dlg.querySelectorAll('input[name=lx-log]'), function (x) { return x.checked; })
337
+ .map(function (x) { return x.value; });
338
+ if (!want.length) { $('lx-errors').textContent = 'Choose at least one log.'; return; }
339
+ var logs = Array.prototype.filter.call(tab.querySelectorAll('details[data-log]'), function (d) {
340
+ return want.indexOf(d.dataset.log) !== -1;
341
+ });
342
+ var stamp = stampNow(), q = (tab.querySelector('input[data-filter]') || {}).value;
343
+ if (format === 'csv') {
344
+ logs.forEach(function (d) {
345
+ var slug = d.dataset.log.toLowerCase().replace(/[^a-z0-9]+/g, '-');
346
+ saveFile(new Blob([csv(logRows(d))], {type: 'text/csv;charset=utf-8'}), 'benchwarmers-' + slug + '-' + stamp + '.csv');
342
347
  });
343
- });
344
- saveFile(new Blob([out.join('\n') + '\n'], {type: 'text/plain;charset=utf-8'}), 'benchwarmers-logs-' + stampNow() + '.txt');
348
+ } else {
349
+ var out = ['Benchwarmers logs, exported ' + new Date().toString()];
350
+ if (q) out.push('Filtered by: ' + q);
351
+ logs.forEach(function (d) {
352
+ out.push('', '== ' + d.dataset.log + ' ==');
353
+ logRows(d).forEach(function (r) { out.push(r.join('\t')); });
354
+ });
355
+ saveFile(new Blob([out.join('\n') + '\n'], {type: 'text/plain;charset=utf-8'}), 'benchwarmers-logs-' + stamp + '.txt');
356
+ }
357
+ dlg.close();
345
358
  });
346
359
 
347
360
  // ---- a link to another page ----
@@ -851,6 +864,7 @@
851
864
  count.textContent = n.count;
852
865
  tab.setAttribute('aria-label', tab.firstChild.textContent + ', ' + n.count + ' open');
853
866
  }
867
+ if ('first' in n) showFirst(n.first);
854
868
  }).catch(function () { location.reload(); }); // could not ask: the old way
855
869
  }
856
870
  // A retired model's rows grey out and hide behind Show retired, as the
@@ -1932,15 +1946,33 @@
1932
1946
  // ---- first run: no endpoint yet, no model, no collection (serve.first_run) -
1933
1947
  // Each waits for its page: it may not have arrived yet.
1934
1948
  var first = $('first-run');
1949
+ // A step done (an endpoint added, a model onboarded, a collection saved) moves first run on
1950
+ // at once: refreshNeeds hands its next step, or "" when the install is set up.
1951
+ function showFirst(html) {
1952
+ var now = $('first-run');
1953
+ if ((now ? now.outerHTML : '') === (html || '')) return;
1954
+ if (now) now.remove();
1955
+ if (html) document.querySelector('main').insertAdjacentHTML('afterbegin', html);
1956
+ first = $('first-run');
1957
+ }
1935
1958
  function firstStep() {
1936
1959
  if (first.dataset.step === 'onboard') { $('onboard-open').click(); return; }
1937
- if (first.dataset.step === 'collection') { show('collections').then(function () { $('c-new').click(); }); return; }
1960
+ // The first collection comes filled in as bench/core, the name the docs, CLIENTS.md and
1961
+ // the Clients page use: up to four of the models just onboarded ticked (two to four is
1962
+ // what a panel wants), never an image generator. Nothing is made until it is saved.
1963
+ if (first.dataset.step === 'collection') {
1964
+ show('collections').then(function () {
1965
+ var members = C.models.filter(function (a) { return fact(a).role !== 'image'; }).slice(0, 4);
1966
+ fillForm(null, {name: 'core', description: 'General second opinions: any question, several models at once.',
1967
+ tags: ['general', 'second-opinion'], members: members});
1968
+ c.name.focus();
1969
+ });
1970
+ return;
1971
+ }
1938
1972
  show('endpoints').then(function () { openEndpoint('add'); });
1939
1973
  }
1940
- if (first) {
1941
- $('first-go').addEventListener('click', firstStep);
1942
- if (first.dataset.step === 'endpoint') firstStep(); // straight into Add: an empty bench offers nothing else
1943
- }
1974
+ on('click', 'first-go', firstStep); // on the document: a new step brings a new button
1975
+ if (first && first.dataset.step === 'endpoint') firstStep(); // straight into Add: an empty bench offers nothing else
1944
1976
 
1945
1977
  // ---- collections: what the bench gateway runs together (its own tab) -----
1946
1978
  // Its elements, the popup's too, are in the page: found again each time it arrives.
@@ -2137,13 +2169,16 @@
2137
2169
  open = null;
2138
2170
  }
2139
2171
  on('close', 'c-form', function () { open = null; });
2140
- function fillForm(row) {
2172
+ // `row`: a saved collection, opened to edit. `draft`: a new one's values filled in
2173
+ // beforehand (first run's core), still new until saved.
2174
+ function fillForm(row, draft) {
2141
2175
  c.errors.textContent = ''; c.done.textContent = ''; c.status.textContent = '';
2142
2176
  open = row ? row.name : null; c.del.hidden = !row;
2143
2177
  c.head.textContent = row ? 'bench/' + row.name : 'New collection';
2144
2178
  if (!c.form.open) c.form.showModal();
2145
- c.name.value = row ? row.name : ''; c.name.readOnly = !!row;
2146
- c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
2179
+ var src = row || draft;
2180
+ c.name.value = src ? src.name : ''; c.name.readOnly = !!row;
2181
+ c.desc.value = src ? src.description : ''; c.tags.value = src ? src.tags.join(', ') : '';
2147
2182
  c.adHoc.checked = !!row && row.ad_hoc;
2148
2183
  c.ownCalls.value = row && row.max_calls !== undefined ? row.max_calls : '';
2149
2184
  c.ownExp.value = row && row.max_explorers !== undefined ? row.max_explorers : '';
@@ -2151,7 +2186,7 @@
2151
2186
  drawTagPicks();
2152
2187
  c.find.value = ''; c.show.value = ''; c.prov.value = '';
2153
2188
  $('c-refresh-out').textContent = '';
2154
- drawMembers(row ? row.members : []);
2189
+ drawMembers(src ? src.members : []);
2155
2190
  c.shape.textContent = '';
2156
2191
  c.shape.appendChild(el('option', {value: ''}, 'automatic'));
2157
2192
  C.shapes.forEach(function (s) {
@@ -2286,6 +2321,7 @@
2286
2321
  // above the list, as onboarding and retiring do.
2287
2322
  function finished(message) {
2288
2323
  c.done.textContent = message; c.status.textContent = message; loadCollections(true);
2324
+ refreshNeeds(); // first run's last step may be done
2289
2325
  setTimeout(closeForm, 1200);
2290
2326
  }
2291
2327
  on('click', 'c-delete', function () {
@@ -2750,6 +2786,9 @@
2750
2786
  // the pages share. It was the whole page, which made every chart wider than
2751
2787
  // its column beside the sidebar, scaled down, with smaller text.
2752
2788
  function width() {
2789
+ // Benchmarks shown with its rail beside the reports: a report's own width, not the column's.
2790
+ var report = document.querySelector('.tab.active .panel.report');
2791
+ if (report && report.clientWidth) return Math.max(520, report.clientWidth - 50);
2753
2792
  var col = document.querySelector('.panes') || document.querySelector('main');
2754
2793
  return Math.max(520, (col.clientWidth || 900) - 50);
2755
2794
  }
@@ -2941,8 +2980,14 @@
2941
2980
  ]
2942
2981
  });
2943
2982
  };
2944
- put('chart-uptime-providers', function (w) { return uptime(U.providers, w); });
2945
- put('chart-uptime-models', function (w) { return uptime(U.models, w); });
2983
+ // One view at a time, as the card's By says: by provider (the default) or by model.
2984
+ var view = (document.querySelector('select[data-uptime-by]') || {}).value || 'providers';
2985
+ ['providers', 'models'].forEach(function (k) {
2986
+ var box = $('chart-uptime-' + k);
2987
+ if (!box) return;
2988
+ box.hidden = k !== view;
2989
+ if (k === view) put('chart-uptime-' + k, function (w) { return uptime(U[k], w); });
2990
+ });
2946
2991
  }
2947
2992
  if (part === 'uptime') return;
2948
2993
  // Decision models: each model's answers grouped by the probability it gave
@@ -3265,41 +3310,59 @@
3265
3310
  });
3266
3311
  });
3267
3312
 
3268
- // Vision, real work: each model's seats on image panels by how they were judged.
3313
+ // Vision, real work: read as Real work is. The one question: when its seat was judged,
3314
+ // did it help? The dot is the share judged useful (checked and helped, or useful with no
3315
+ // claims to check), sized by how many were judged; the line where that share likely sits
3316
+ // (95% Wilson), ranked by its low end. Beside it, in words: how often it answered at all,
3317
+ // and the claims checked by hand. Seats never judged or never answered say nothing of
3318
+ // its worth: they are in the words and the table, not the dot.
3269
3319
  put('chart-vision-work', function (w) {
3270
- var W = C.vision.work, kinds = C.vision.seatKinds, data = [], total = {};
3320
+ var W = C.vision.work, kinds = C.vision.seatKinds, R = [], none = [];
3271
3321
  Object.keys(W).forEach(function (m) {
3272
- kinds.forEach(function (k) { if (W[m][k]) data.push({model: m, kind: k, count: W[m][k]}); total[m] = (total[m] || 0) + W[m][k]; });
3273
- });
3274
- if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
3275
- // The models doing the most image work first: their bars are the evidence. Under FEW
3276
- // seats a row was mostly "no answer" and said little: those are listed under the chart.
3277
- var all = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
3278
- var models = all.filter(function (m) { return total[m] >= FEW; });
3279
- var few = all.filter(function (m) { return total[m] < FEW; });
3280
- if (!models.length) { models = all; few = []; }
3281
- data = data.filter(function (d) { return models.indexOf(d.model) !== -1; });
3282
- var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
3283
- 'no use, no claims': warn, 'not judged': line, 'no answer': muted};
3284
- // The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
3285
- var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
3286
- return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
3322
+ var v = W[m], n = function (k) { return v[k] || 0; }, total = 0;
3323
+ kinds.forEach(function (k) { total += n(k); });
3324
+ if (!total) return;
3325
+ var judged = n('checked, helped') + n('checked, did not help') + n('useful, no claims') + n('no use, no claims');
3326
+ var useful = n('checked, helped') + n('useful, no claims');
3327
+ var r = {model: m, total: total, judged: judged, useful: useful, answered: total - n('no answer'),
3328
+ real: v.real || 0, wrongC: v.false || 0};
3329
+ if (!judged) { none.push(r); return; }
3330
+ var b = wilson(useful, judged);
3331
+ r.p = useful / judged; r.lo = b[0]; r.hi = b[1];
3332
+ r.verdict = b[0] >= 0.5 ? 'helps' : b[1] < 0.5 ? 'does not help' : 'too few to say';
3333
+ R.push(r);
3287
3334
  });
3335
+ if (!R.length && !none.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
3336
+ R.sort(function (a, b) { return b.lo - a.lo || b.judged - a.judged; });
3288
3337
  var narrowV = w < 560, box = el('div');
3289
- box.appendChild(Plot.plot({
3290
- width: w, height: rows(models.length), style: style, marginLeft: narrowV ? 150 : 260, marginRight: narrowV ? 20 : 150,
3291
- x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
3292
- y: {domain: models, label: null, tickFormat: function (m) { return fit(m, narrowV ? 16 : 30) + ' (' + total[m] + ')'; }},
3293
- color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
3338
+ function words(r) {
3339
+ return r.useful + '/' + r.judged + (narrowV ? '' : ' · ' + Math.round(r.answered / r.total * 100) + '% answered'
3340
+ + (r.real + r.wrongC ? ' · claims ' + r.real + '/' + (r.real + r.wrongC) : ''));
3341
+ }
3342
+ if (R.length) box.appendChild(Plot.plot({
3343
+ width: w, height: 24 * R.length + 90, style: style, marginLeft: narrowV ? 130 : 190,
3344
+ marginRight: narrowV ? 50 : 260, marginTop: 34,
3345
+ x: {domain: [0, 1], label: 'Judged useful, share of its judged seats →', tickFormat: '.0%', ticks: 5, grid: true},
3346
+ y: {domain: R.map(function (r) { return r.model; }), label: null,
3347
+ tickFormat: function (m) { return fit(m, narrowV ? 16 : 26); }},
3348
+ r: {domain: [0, Math.max.apply(null, R.map(function (r) { return r.judged; }))], range: [3, 11]},
3349
+ color: {domain: ['helps', 'too few to say', 'does not help'], range: [good, muted, err], legend: true},
3294
3350
  marks: [
3295
- Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
3296
- title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
3297
- Plot.text(narrowV ? [] : claims.filter(function (c) { return models.indexOf(c.model) !== -1; }),
3298
- {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
3351
+ Plot.ruleX([0.5], {stroke: line}),
3352
+ Plot.ruleY(R, {y: 'model', x1: 'lo', x2: 'hi', stroke: 'verdict', strokeWidth: 2, strokeOpacity: 0.55}),
3353
+ Plot.dot(R, {x: 'p', y: 'model', r: 'judged', fill: 'verdict', stroke: halo, strokeWidth: 1.5,
3354
+ title: function (r) {
3355
+ return r.model + '\njudged useful ' + r.useful + ' of ' + r.judged + ' judged seats ('
3356
+ + Math.round(r.p * 100) + '%), likely between ' + Math.round(r.lo * 100) + '% and ' + Math.round(r.hi * 100)
3357
+ + '%\nanswered ' + r.answered + ' of ' + r.total + ' seats on image panels'
3358
+ + (r.real + r.wrongC ? '\nclaims checked by hand: ' + r.real + ' right of ' + (r.real + r.wrongC) : '');
3359
+ }}),
3360
+ Plot.text(R, {x: 1, y: 'model', dx: 10, textAnchor: 'start', fill: ink, fontSize: 11, text: words})
3299
3361
  ]
3300
3362
  }));
3301
- if (few.length) box.appendChild(el('p', {class: 'hint'}, 'Under ' + FEW + ' seats, so no bar: '
3302
- + few.map(function (m) { return m + ' (' + total[m] + ')'; }).join(', ') + '. The table has them all.'));
3363
+ if (none.length) box.appendChild(el('p', {class: 'hint'}, 'No seat judged yet, so no dot: '
3364
+ + none.map(function (r) { return r.model + ' (' + r.total + ' seats, answered ' + r.answered + ')'; }).join(', ')
3365
+ + '. The table has every seat.'));
3303
3366
  return box;
3304
3367
  });
3305
3368
 
@@ -3338,10 +3401,50 @@
3338
3401
  });
3339
3402
 
3340
3403
  }
3341
- onLoad.suite = function () { drawCharts('suite'); };
3404
+ onLoad.suite = function () { drawCharts('suite'); jumpSpy(); };
3405
+ // Benchmarks' jump bar: the report in view is marked (aria-current) and its link kept in
3406
+ // sight in a bar that scrolls sideways. Set up again after a live swap (setUp, onLoad).
3407
+ var spy = null;
3408
+ function jumpSpy() {
3409
+ if (spy) spy.disconnect();
3410
+ var nav = document.querySelector('#tab-suite .jump');
3411
+ if (!nav || !window.IntersectionObserver) return;
3412
+ var links = {}, seen = {}, current = null;
3413
+ nav.querySelectorAll('a').forEach(function (a) { links[a.getAttribute('href').slice(1)] = a; });
3414
+ // A report counts as in view while it crosses a thin band 15-20% down the window: just
3415
+ // under where a jump lands it, so the one jumped to is the one marked.
3416
+ spy = new IntersectionObserver(function (entries) {
3417
+ entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting; });
3418
+ var id = Object.keys(links).filter(function (k) { return seen[k]; })[0];
3419
+ if (!id || id === current) return;
3420
+ if (current) links[current].removeAttribute('aria-current');
3421
+ current = id;
3422
+ links[id].setAttribute('aria-current', 'true');
3423
+ var a = links[id];
3424
+ if (a.offsetLeft < nav.scrollLeft || a.offsetLeft + a.offsetWidth > nav.scrollLeft + nav.clientWidth) {
3425
+ nav.scrollLeft = a.offsetLeft - 16;
3426
+ }
3427
+ }, {rootMargin: '-15% 0px -80% 0px'});
3428
+ document.querySelectorAll('#tab-suite .panel.report').forEach(function (p) { spy.observe(p); });
3429
+ }
3430
+ // A jump scrolls smoothly (unless motion is reduced) and moves focus to the report, so
3431
+ // a keyboard user carries on from there; the address keeps the report's #, as a link would.
3432
+ document.addEventListener('click', function (e) {
3433
+ var a = e.target.closest('.jump a');
3434
+ if (!a) return;
3435
+ var target = $(a.getAttribute('href').slice(1));
3436
+ if (!target) return;
3437
+ e.preventDefault();
3438
+ var still = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
3439
+ target.scrollIntoView({behavior: still ? 'auto' : 'smooth', block: 'start'});
3440
+ target.setAttribute('tabindex', '-1');
3441
+ target.focus({preventScroll: true});
3442
+ history.replaceState(null, '', a.getAttribute('href'));
3443
+ });
3342
3444
  // Uptime's or Trend's window: that window's tables (and Uptime's list) shown, its charts drawn again.
3343
3445
  document.addEventListener('change', function (ev) {
3344
3446
  var s = ev.target, part = s.matches('select[data-uptime-days]') ? 'uptime' : s.matches('select[data-trend-days]') ? 'trend' : '';
3447
+ if (s.matches('select[data-uptime-by]')) { drawCharts('uptime'); return; } // the other view, same window
3345
3448
  if (!part) return;
3346
3449
  s.closest('.panel').querySelectorAll('[data-' + part + '-window]').forEach(function (d) {
3347
3450
  d.hidden = d.getAttribute('data-' + part + '-window') !== s.value;