@wendellresearch-ai/benchwarmers 0.3.3 → 0.5.0

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.
Files changed (50) hide show
  1. package/GATEWAY.md +9 -5
  2. package/PRIVACY.md +9 -3
  3. package/README.md +31 -8
  4. package/USAGE.md +81 -15
  5. package/VERSION +1 -1
  6. package/cli.py +17 -5
  7. package/gateway.py +34 -2
  8. package/launcher.py +2 -1
  9. package/metrics/aliases.py +24 -3
  10. package/metrics/discover.py +68 -18
  11. package/metrics/endpoints.py +36 -4
  12. package/metrics/model_roles.py +3 -1
  13. package/metrics/pacing.py +23 -3
  14. package/metrics/quotaview.py +460 -0
  15. package/metrics/route.py +3 -2
  16. package/metrics/scheduler.py +2 -1
  17. package/metrics/sources.py +29 -6
  18. package/metrics/wrong.py +3 -1
  19. package/package.json +1 -1
  20. package/scripts/check.py +3 -0
  21. package/scripts/onboard.py +12 -1
  22. package/scripts/test_hubs.py +10 -5
  23. package/scripts/test_no_owui.py +14 -2
  24. package/scripts/test_quota.py +12 -2
  25. package/scripts/test_serve_edit.py +58 -5
  26. package/scripts/test_server_json.py +22 -2
  27. package/serve.py +570 -128
  28. package/suite/README.md +33 -0
  29. package/suite/pace.py +8 -4
  30. package/suite/run.py +144 -16
  31. package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
  32. package/suite/tasks/vision-bars/task.json +40 -0
  33. package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
  34. package/suite/tasks/vision-invoice/task.json +40 -0
  35. package/suite/tasks/vision-label/fixtures/label.png +0 -0
  36. package/suite/tasks/vision-label/task.json +52 -0
  37. package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
  38. package/suite/tasks/vision-receipt/task.json +44 -0
  39. package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
  40. package/suite/tasks/vision-screen/task.json +48 -0
  41. package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
  42. package/suite/tasks/vision-shapes/task.json +40 -0
  43. package/suite/tasks/vision-table/fixtures/table.png +0 -0
  44. package/suite/tasks/vision-table/task.json +40 -0
  45. package/suite/test_vision_tasks.py +201 -0
  46. package/suite/vision_keys.py +74 -0
  47. package/suite/vision_sample.py +232 -0
  48. package/suite/vision_tasks.py +338 -0
  49. package/ui/bench.css +71 -6
  50. package/ui/bench.js +543 -49
package/ui/bench.js CHANGED
@@ -20,9 +20,11 @@
20
20
  // The page's own confirm, a Promise of true/false: window.confirm is
21
21
  // silently false where a browser blocks dialogs. Opens over any open popup.
22
22
  var askD = $('ask-dialog'), askDone = null;
23
- function ask(text, yes, danger) {
23
+ function ask(text, yes, danger, onboard) {
24
24
  if (askDone) askDone(false);
25
25
  $('ask-text').textContent = text;
26
+ $('ask-onboard').hidden = !onboard; // Discover's Add: the Onboard dialog's mode and Vision box
27
+ $('ask-vision').checked = false;
26
28
  $('ask-yes').textContent = yes || 'OK';
27
29
  $('ask-yes').className = 'btn ' + (danger ? 'danger' : 'primary');
28
30
  askD.showModal(); $('ask-no').focus();
@@ -33,6 +35,34 @@
33
35
  $('ask-no').addEventListener('click', function () { answer(false); });
34
36
  askD.addEventListener('close', function () { answer(false); }); // Escape
35
37
 
38
+ // ---- restart (serve.restart): the page reloads once the new one answers ----
39
+ var rs = $('restart');
40
+ function restart(force) {
41
+ var out = $('restart-out');
42
+ rs.disabled = true; out.textContent = 'Restarting…';
43
+ post('/api/restart', {force: force}).then(function (res) {
44
+ if (res.ok) return back(Date.now());
45
+ rs.disabled = false; out.textContent = '';
46
+ if (!res.busy) { listErrors(out, res.errors); return; }
47
+ ask('Still in progress: ' + res.errors.join('; ') + '. Restart anyway and cut them off?', 'Restart anyway', true)
48
+ .then(function (yes) { if (yes) restart(true); });
49
+ }).catch(function (err) { rs.disabled = false; out.textContent = String(err); });
50
+ }
51
+ // The old one stops half a second after answering; /health answers again once the new one is up.
52
+ function back(since) {
53
+ setTimeout(function () {
54
+ fetch('/health', {cache: 'no-store'}).then(function (r) { if (!r.ok) throw r.status; location.reload(); })
55
+ .catch(function () {
56
+ if (Date.now() - since < 120000) back(since);
57
+ else $('restart-out').textContent = 'Not back after two minutes: look at whatever runs it.';
58
+ });
59
+ }, 1500);
60
+ }
61
+ if (rs) rs.addEventListener('click', function () {
62
+ ask('Restart Benchwarmers? The dashboard and the gateway on this port stop for a few seconds.', 'Restart')
63
+ .then(function (yes) { if (yes) restart(false); });
64
+ });
65
+
36
66
  // ---- tabs, remembered per browser ----------------------------------
37
67
  var tabs = document.querySelectorAll('.tabs button[data-tab]'); // not the sidebar's own toggle
38
68
  var onShow = {}; // a page's loader, run each time it is shown
@@ -509,30 +539,42 @@
509
539
  o.picker.appendChild(d);
510
540
  }
511
541
  }
512
- function loadCandidates() {
513
- o.picker.textContent = ''; o.picker.appendChild(el('p', {class: 'empty'}, 'Asking the endpoints what they serve…'));
542
+ // The server keeps each endpoint's list a few minutes (serve.LISTED_FOR);
543
+ // Refresh asks them all again.
544
+ function showAge(s) {
545
+ var age = $('o-age');
546
+ age.hidden = s == null;
547
+ if (s == null) return;
548
+ age.firstChild.textContent = s < 60 ? 'Listed just now.' : 'Listed ' + Math.round(s / 60) + ' min ago.';
549
+ }
550
+ function loadCandidates(fresh) {
551
+ showAge(null);
552
+ o.picker.textContent = ''; o.picker.appendChild(el('p', {class: 'empty'}, 'Please wait. Gathering models from your endpoints…'));
514
553
  o.start.disabled = o.dismiss.disabled = true;
515
- o.find.readOnly = true; // typing before the list arrives is lost, or lands on a row
516
- fetch('/api/candidates').then(function (r) { return r.json(); }).then(function (res) {
517
- o.find.readOnly = false; o.find.focus();
554
+ // Typing before the list arrives is lost, or lands on a row; the mode waits with it.
555
+ o.find.readOnly = true; $('o-mode').disabled = $('o-vision').disabled = true;
556
+ function ready() { o.find.readOnly = false; $('o-mode').disabled = $('o-vision').disabled = false; }
557
+ fetch('/api/candidates' + (fresh ? '?fresh=1' : '')).then(function (r) { return r.json(); }).then(function (res) {
558
+ ready(); o.find.focus();
518
559
  if (!res.ok) { o.picker.textContent = ''; listErrors(o.errors, res.errors); return; }
519
560
  if (res.errors.length) listErrors(o.errors, res.errors); // one endpoint down; the rest listed
520
- data = res;
561
+ data = res; showAge(res.age);
521
562
  // Only what is still offered stays ticked.
522
563
  var offered = {};
523
564
  res.candidates.forEach(function (c) { offered[c.key] = 1; });
524
565
  Object.keys(chosen).forEach(function (k) { if (!offered[k]) delete chosen[k]; });
525
566
  renderPicker(); armStart();
526
- }).catch(function (err) { o.find.readOnly = false; o.errors.textContent = String(err); });
567
+ }).catch(function (err) { ready(); o.errors.textContent = String(err); });
527
568
  }
528
569
  $('onboard-open').addEventListener('click', function () {
529
570
  preStep = null;
530
571
  o.errors.textContent = ''; o.find.value = ''; o.choose.hidden = false; o.run.hidden = true;
531
- $('o-queue').hidden = true; chosen = {}; waitToken++;
572
+ $('o-queue').hidden = true; chosen = {}; waitToken++; $('o-vision').checked = false;
532
573
  o.start.hidden = o.dismiss.hidden = false; armStart(); o.title.textContent = 'Onboard a model';
533
574
  od.showModal(); o.find.focus(); loadCandidates();
534
575
  });
535
576
  o.find.addEventListener('input', renderPicker);
577
+ $('o-refresh').addEventListener('click', function () { o.errors.textContent = ''; loadCandidates(true); });
536
578
  o.close.addEventListener('click', function () { od.close(); });
537
579
  od.addEventListener('close', function () { if (timer) { clearInterval(timer); timer = null; } });
538
580
 
@@ -573,8 +615,8 @@
573
615
  // model at a time. The popup closes: the header's chip shows the batch, and
574
616
  // its Details the queue. Refused, it stays open with the reasons.
575
617
  o.start.addEventListener('click', function () {
576
- var mode = $('o-mode').value;
577
- var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode}, chosen[k]); });
618
+ var mode = $('o-mode').value, vision = $('o-vision').checked;
619
+ var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode, vision: vision}, chosen[k]); });
578
620
  if (!items.length) return;
579
621
  o.errors.textContent = ''; o.start.disabled = o.dismiss.disabled = true; o.start.textContent = 'Starting…';
580
622
  post('/api/onboard-queue', {items: items}).then(function (res) {
@@ -731,19 +773,21 @@
731
773
  function addVia(x, b) {
732
774
  var direct = x.via === 'direct';
733
775
  ask('Add ' + x.model + ' via ' + x.label + (x.free ? '' : ' (PAID)') + '?\n\n'
734
- + (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint, a 4-call probe, then the suite.'
776
+ + (direct ? 'The bench only: register it on the ' + x.endpoint + ' endpoint and onboard it as chosen below.'
735
777
  // A LiteLLM or CLI Proxy API route says its own steps: only what that hub takes.
736
778
  : x.steps ? x.steps.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n')
737
779
  : '1. Open WebUI: add it to the ' + x.connection + ' connection\'s Model IDs (if that connection lists them)\n'
738
780
  + '2. Open WebUI: a public model record, tagged and with the logo of the connection\'s other models\n'
739
- + '3. The bench: register, a 4-call probe, then the suite'), 'Add').then(function (yes) { if (yes) addNow(x, b, direct); });
781
+ + '3. The bench: register it and onboard it as chosen below'), 'Add', false, true).then(function (yes) {
782
+ if (yes) addNow(x, b, direct, {mode: $('ask-mode').value, vision: $('ask-vision').checked});
783
+ });
740
784
  }
741
- function addNow(x, b, direct) {
785
+ function addNow(x, b, direct, how) {
742
786
  var title = 'Adding ' + x.model + ' via ' + x.label;
743
787
  $('d-errors').textContent = '';
744
788
  if (direct) {
745
789
  b.disabled = true; b.textContent = 'Adding…';
746
- post('/api/onboard-queue', {items: [{served: x.model, endpoint: x.endpoint}]}).then(function (res) {
790
+ post('/api/onboard-queue', {items: [Object.assign({served: x.model, endpoint: x.endpoint}, how)]}).then(function (res) {
747
791
  if (!res.ok) { listErrors($('d-errors'), res.errors); b.disabled = false; b.textContent = 'Add via this'; return; }
748
792
  dd.close(); preStep = null; o.errors.textContent = ''; od.showModal();
749
793
  afterQueued(res, {served: x.model, endpoint: x.endpoint}, title, '');
@@ -758,7 +802,7 @@
758
802
  runView(title, 'Adding it to ' + x.label + '…');
759
803
  oq.box.hidden = false; oq.chip.classList.remove('done', 'fail'); oq.chip.style.setProperty('--fill', '3%');
760
804
  oq.text.textContent = oq.text.title = title; oq.stop.hidden = oq.clear.hidden = true;
761
- post('/api/discover/add', {connection: x.connection, model: x.model, endpoint: x.endpoint}).then(function (res) {
805
+ post('/api/discover/add', Object.assign({connection: x.connection, model: x.model, endpoint: x.endpoint}, how)).then(function (res) {
762
806
  var hubDone = !!(res.served && res.steps && res.steps.length); // in the hub, even if queueing it was refused
763
807
  preStep.state = hubDone ? 'done' : 'fail';
764
808
  preStep.note = (res.steps || []).join('. ') + (hubDone ? '' : (res.steps && res.steps.length ? '. ' : '') + res.errors.join(' '));
@@ -790,8 +834,16 @@
790
834
  if (res.errors.length) listErrors($('d-errors'), res.errors); // a hub that could not be asked
791
835
  var r = res.model, p = $('d-profile'), list = $('d-routes');
792
836
  $('d-title').textContent = r.name;
793
- $('d-served').textContent = r.provider_name + ' · ' + r.model + ' · listed by ' + r.sources.join(' and ')
794
- + (r.on_bench_as ? ' · on the bench as ' + r.on_bench_as : '');
837
+ // Each source a link: FreeLLMAPI's page for the model where its sitemap has one, else its catalog.
838
+ var sv = $('d-served');
839
+ sv.textContent = r.provider_name + ' · ' + r.model + ' · listed by ';
840
+ r.sources.forEach(function (s, i) {
841
+ var fl = s === 'freellmapi';
842
+ if (i) sv.appendChild(document.createTextNode(' and '));
843
+ sv.appendChild(el('a', {href: fl ? r.page || 'https://freellmapi.co/models' : 'https://models.dev/',
844
+ target: '_blank', rel: 'noopener'}, fl ? 'FreeLLMAPI' : 'models.dev'));
845
+ });
846
+ if (r.on_bench_as) sv.appendChild(document.createTextNode(' · on the bench as ' + r.on_bench_as));
795
847
  p.textContent = '';
796
848
  [['Input', r.in_mods], ['Output', r.out_mods]].forEach(function (x) {
797
849
  var row = el('div', {class: 'prof'});
@@ -802,16 +854,26 @@
802
854
  var can = [['tools', r.tools], ['vision', r.vision], ['reasons', r.reasoning]]
803
855
  .filter(function (x) { return x[1]; }).map(function (x) { return x[0]; });
804
856
  var facts = [
857
+ r.rank != null ? 'smartest #' + r.rank + (r.speed ? ', fastest #' + r.speed : '') + ' on FreeLLMAPI' : '',
858
+ r.size || '',
805
859
  r.context ? 'context ' + Number(r.context).toLocaleString() + ' tokens' : 'context unknown',
806
860
  r.output ? 'output up to ' + Number(r.output).toLocaleString() : '',
807
861
  can.length ? 'can: ' + can.join(', ') : '',
808
862
  price(r),
809
863
  r.limits ? 'limits ' + limits(r.limits) : '',
864
+ r.budget ? r.budget + ' tokens a month' : '',
810
865
  r.released ? 'released ' + r.released : '',
811
866
  r.knowledge ? 'knowledge to ' + r.knowledge : '',
812
867
  r.open_weights != null ? (r.open_weights ? 'open weights' : 'closed weights') : '',
813
868
  'first seen ' + (r.first_seen === 'baseline' ? 'before the list began' : r.first_seen)];
814
869
  p.appendChild(el('div', {class: 'prof'}, facts.filter(Boolean).join(' · ')));
870
+ // FreeLLMAPI's operating notes for this provider, e.g. thinking tokens that eat the output cap.
871
+ (r.quirks || []).forEach(function (q) {
872
+ var row = el('div', {class: 'prof'});
873
+ row.appendChild(el('strong', {}, q.title + ': '));
874
+ row.appendChild(document.createTextNode(q.body || ''));
875
+ p.appendChild(row);
876
+ });
815
877
  if (r.also_on && r.also_on.length) p.appendChild(el('div', {class: 'prof muted'}, 'Also on the bench as ' + r.also_on.join(', ')));
816
878
  list.textContent = '';
817
879
  res.routes.forEach(function (x) {
@@ -1267,7 +1329,8 @@
1267
1329
  shape: $('c-shape'), desc: $('c-desc'), tags: $('c-tags'), errors: $('c-errors'), done: $('c-done'),
1268
1330
  head: $('c-form-head'), maxCalls: $('g-max-calls'), maxExp: $('g-max-explorers'),
1269
1331
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1270
- del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter')};
1332
+ del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1333
+ seats: $('c-seats')};
1271
1334
  var C = null, open = null; // the collection whose details are showing
1272
1335
  function explorersFor(n) {
1273
1336
  return Math.max(0, Math.min(C.settings.max_explorers, C.settings.max_calls - n));
@@ -1287,10 +1350,37 @@
1287
1350
  (shown.length < rows.length ? ' · showing ' + shown.length + ' of ' + rows.length + ' models' : '') +
1288
1351
  (paid ? ' (' + paid + ' paid hidden by the free tag)' : '') +
1289
1352
  (greyed ? ' · ' + greyed + ' greyed out by the tags (hover one for why)' : '') +
1290
- (clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '');
1353
+ (clash ? ' · ' + clash + ' ticked member' + (clash === 1 ? ' does' : 's do') + ' not fit the tags' : '') +
1354
+ twice().map(function (t) {
1355
+ return ' · ' + t.name + ' is ticked on ' + t.on.join(' and ') + ': one model agrees with itself, it does not check itself';
1356
+ }).join('');
1291
1357
  c.members.querySelectorAll('input').forEach(function (x) {
1292
1358
  x.disabled = !x.checked && (n >= C.settings.max_calls || !!x.dataset.blocked);
1293
1359
  });
1360
+ drawSeats(ex);
1361
+ }
1362
+ function fact(alias) { return (C.facts || {})[alias] || {}; }
1363
+ // The same model ticked on two providers (route.family_of, as the gateway
1364
+ // keeps explorers off a member's model): allowed, and said.
1365
+ function twice() {
1366
+ var by = {};
1367
+ picked().forEach(function (a) { var f = fact(a).family || a; (by[f] = by[f] || []).push(a); });
1368
+ return Object.keys(by).filter(function (f) { return by[f].length > 1; }).map(function (f) {
1369
+ var row = c.members.querySelector('.m-row[data-family="' + CSS.escape(f) + '"]');
1370
+ return {name: row ? row.dataset.name : by[f][0], on: by[f].map(function (a) { return fact(a).provider || a; })};
1371
+ });
1372
+ }
1373
+ // The panel as it will sit, as the Collections list draws it.
1374
+ function drawSeats(ex) {
1375
+ c.seats.textContent = '';
1376
+ picked().forEach(function (a) {
1377
+ var seat = el('span', {class: 'seat'}, a);
1378
+ if (fact(a).provider) seat.appendChild(el('span', {class: 'k after'}, fact(a).provider));
1379
+ c.seats.appendChild(seat);
1380
+ });
1381
+ for (var i = 0; i < ex; i++) {
1382
+ c.seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (c.shape.value ? ' · ' + c.shape.value : '')));
1383
+ }
1294
1384
  }
1295
1385
  // Tags that say what every member must do: why a model does not fit them
1296
1386
  // (none: it fits). From collections_view's facts; an unknown context is
@@ -1328,20 +1418,28 @@
1328
1418
  lab.classList.toggle('clash', why.length > 0 && box.checked);
1329
1419
  lab.dataset.paid = paid ? '1' : '';
1330
1420
  if (why.length) box.dataset.blocked = '1'; else delete box.dataset.blocked;
1421
+ // A chip says only the provider: the alias and served name are its first line.
1331
1422
  var served = lab.dataset.served;
1332
- lab.title = (served && served !== box.value ? served : '') + (why.length ? (served && served !== box.value ? '\n' : '') + why.join('\n') : '');
1423
+ lab.title = [box.value + (served && served !== box.value ? ' (' + served + ')' : '')].concat(why).join('\n');
1333
1424
  });
1334
1425
  filterMembers();
1335
1426
  }
1336
1427
  // The member search: the filter boxes' match (any text, case-insensitive),
1337
- // over alias and served name. It only hides rows, and never a ticked one, so
1338
- // what is picked is always in view and every count still includes it.
1428
+ // over the model's name and each chip's alias, served name and provider.
1429
+ // It only hides chips, and never a ticked one, so what is picked is always
1430
+ // in view and every count still includes it; a row goes when all its chips do.
1339
1431
  function filterMembers() {
1340
1432
  var q = c.find.value.trim().toLowerCase();
1341
- c.members.querySelectorAll('label.check').forEach(function (lab) {
1342
- var box = lab.querySelector('input');
1343
- lab.hidden = !box.checked && (!!lab.dataset.paid ||
1344
- !!q && (box.value + ' ' + (lab.dataset.served || '')).toLowerCase().indexOf(q) === -1);
1433
+ c.members.querySelectorAll('.m-row').forEach(function (row) {
1434
+ var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
1435
+ row.querySelectorAll('label.check').forEach(function (lab) {
1436
+ var box = lab.querySelector('input');
1437
+ var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
1438
+ lab.textContent).toLowerCase();
1439
+ lab.hidden = !box.checked && (!!lab.dataset.paid || !named && text.indexOf(q) === -1);
1440
+ any = any || !lab.hidden;
1441
+ });
1442
+ row.hidden = !any;
1345
1443
  });
1346
1444
  recount();
1347
1445
  }
@@ -1372,6 +1470,7 @@
1372
1470
  c.tags.addEventListener('input', function () { drawTagPicks(); applyTags(); });
1373
1471
  function showMerger() { $('c-merger-row').hidden = c.reply.value !== 'merged'; }
1374
1472
  c.reply.addEventListener('change', showMerger);
1473
+ c.shape.addEventListener('change', recount); // the explorer seats say their shape
1375
1474
  // A collection opens in a popup, as every other edit on the dashboard does:
1376
1475
  // the one clicked, or a new one from New collection.
1377
1476
  function closeForm() {
@@ -1388,14 +1487,37 @@
1388
1487
  c.desc.value = row ? row.description : ''; c.tags.value = row ? row.tags.join(', ') : '';
1389
1488
  drawTagPicks();
1390
1489
  c.members.textContent = ''; c.find.value = '';
1391
- C.models.forEach(function (alias) {
1392
- var box = el('input', {type: 'checkbox', value: alias});
1393
- box.checked = !!row && row.members.indexOf(alias) !== -1;
1394
- box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
1395
- var served = (C.served || {})[alias] || '';
1396
- var lab = el('label', {class: 'check', 'data-served': served});
1397
- lab.appendChild(box); lab.appendChild(el('span', {class: 'mono'}, alias));
1398
- c.members.appendChild(lab);
1490
+ // One row per model, named by its shortest alias, with what the first
1491
+ // route says it holds and can do; each provider it is on is a chip that
1492
+ // ticks that alias. A chip whose window differs from the row's says its own.
1493
+ var groups = {};
1494
+ C.models.forEach(function (a) { var f = fact(a).family || a; (groups[f] = groups[f] || []).push(a); });
1495
+ Object.keys(groups).map(function (f) {
1496
+ var routes = groups[f].slice().sort(function (x, y) { return x.length - y.length || (x < y ? -1 : 1); });
1497
+ return {family: f, name: routes[0], routes: routes};
1498
+ }).sort(function (x, y) { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1; }).forEach(function (g) {
1499
+ var first = fact(g.name), caps = first.caps || {};
1500
+ var can = ['vision', 'tools'].filter(function (k) { return caps[k]; });
1501
+ var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
1502
+ var name = el('div', {class: 'm-name'});
1503
+ name.appendChild(el('span', {class: 'mono'}, g.name));
1504
+ name.appendChild(el('span', {class: 'm-facts'}, [first.context ? tokens(first.context) : '', can.join(', '),
1505
+ first.price === 'unknown' ? '' : first.price].filter(Boolean).join(' · ')));
1506
+ line.appendChild(name);
1507
+ var chips = el('div', {class: 'm-routes'});
1508
+ g.routes.forEach(function (alias) {
1509
+ var f = fact(alias);
1510
+ var box = el('input', {type: 'checkbox', value: alias, 'aria-label': alias + ' on ' + (f.provider || 'its endpoint')});
1511
+ box.checked = !!row && row.members.indexOf(alias) !== -1;
1512
+ box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
1513
+ var lab = el('label', {class: 'check', 'data-served': (C.served || {})[alias] || ''});
1514
+ lab.appendChild(box);
1515
+ lab.appendChild(el('span', null, (f.provider || alias) +
1516
+ (f.context && first.context && f.context !== first.context ? ' · ' + tokens(f.context) : '')));
1517
+ chips.appendChild(lab);
1518
+ });
1519
+ line.appendChild(chips);
1520
+ c.members.appendChild(line);
1399
1521
  });
1400
1522
  c.shape.textContent = '';
1401
1523
  c.shape.appendChild(el('option', {value: ''}, 'automatic'));
@@ -1406,10 +1528,18 @@
1406
1528
  c.merger.textContent = '';
1407
1529
  c.merger.appendChild(el('option', {value: ''}, 'automatic'));
1408
1530
  C.models.forEach(function (alias) {
1409
- var o = el('option', {value: alias}, alias); if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
1531
+ var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
1532
+ if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
1410
1533
  });
1411
1534
  showMerger(); applyTags();
1412
1535
  }
1536
+ // What a collection's members can all do, in the words the tag checks use.
1537
+ var CAN = {reasoning: 'reason'};
1538
+ Object.keys(CAP_TAGS).forEach(function (t) { CAN[CAP_TAGS[t][0]] = CAP_TAGS[t][1]; });
1539
+ function tokens(n) { // 512k, 1M (2^20), 1.9M
1540
+ return n >= 1000000 ? String(Math.round(n / 104857.6) / 10).replace(/\.0$/, '') + 'M'
1541
+ : Math.floor(n / 1000).toLocaleString() + 'k';
1542
+ }
1413
1543
  function loadCollections(keep) {
1414
1544
  fetch('/api/collections').then(function (r) { return r.json(); }).then(function (res) {
1415
1545
  C = res; c.list.textContent = '';
@@ -1418,24 +1548,38 @@
1418
1548
  var box = el('button', {class: 'endpoint c-row', type: 'button', 'data-name': row.name,
1419
1549
  'aria-label': 'Open bench/' + row.name});
1420
1550
  var left = el('div');
1421
- var title = el('div', {class: 'what'});
1422
- title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name + ' '));
1423
- row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip', style: 'margin-left:.3rem'}, t)); });
1551
+ var title = el('div', {class: 'top'});
1552
+ title.appendChild(el('span', {class: 'alias'}, 'bench/' + row.name));
1553
+ row.tags.forEach(function (t) { title.appendChild(el('span', {class: 'chip'}, t)); });
1424
1554
  left.appendChild(title);
1425
- if (row.description) left.appendChild(el('div', {}, row.description));
1426
- left.appendChild(el('div', {class: 'base'}, row.members.join(', ') + ' + ' + row.explorers + ' explorer' +
1427
- (row.explorers === 1 ? '' : 's') + (row.shape ? ' (' + row.shape + ')' : '') +
1428
- (row.reply === 'merged' ? ' · merged by ' + (row.merger || 'verdicts') : '')));
1555
+ if (row.description) left.appendChild(el('p', {class: 'desc'}, row.description));
1556
+ // The panel as it sits: a seat per member, an empty one per explorer,
1557
+ // and the merger after an arrow.
1558
+ var seats = el('div', {class: 'seats'});
1559
+ row.members.forEach(function (m) { seats.appendChild(el('span', {class: 'seat'}, m)); });
1560
+ for (var i = 0; i < row.explorers; i++) {
1561
+ seats.appendChild(el('span', {class: 'seat explorer'}, 'explorer' + (row.shape ? ' · ' + row.shape : '')));
1562
+ }
1563
+ if (row.reply === 'merged') {
1564
+ var merge = el('span', {class: 'merge'}), seat = el('span', {class: 'seat merger'});
1565
+ merge.appendChild(el('span', {'aria-hidden': 'true'}, '→'));
1566
+ seat.appendChild(el('span', {class: 'k'}, 'merger'));
1567
+ seat.appendChild(document.createTextNode(row.merger || 'chosen by verdicts'));
1568
+ merge.appendChild(seat); seats.appendChild(merge);
1569
+ }
1570
+ left.appendChild(seats);
1429
1571
  var t = row.together;
1430
1572
  if (t) {
1431
1573
  var lim = Object.keys(t.limits || {}).filter(function (k) { return t.limits[k]; })
1432
1574
  .map(function (k) { return t.limits[k].toLocaleString() + ' ' + k; });
1433
- left.appendChild(el('div', {class: 'base'}, 'Together: ' +
1434
- (t.context_window ? 'prompts up to ' + Math.floor(t.context_window / 1000).toLocaleString() + 'k tokens' : 'context unknown') +
1435
- (t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + ')' : '') +
1436
- ' · all can: ' + (t.capabilities.join(', ') || 'nothing known in common') +
1437
- ' · ' + t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's') +
1438
- (lim.length ? ' · tightest ' + lim.join(', ') : '')));
1575
+ var can = t.capabilities.map(function (k) { return CAN[k] || k; });
1576
+ var n = t.connections.length + ' connection' + (t.connections.length === 1 ? '' : 's');
1577
+ left.appendChild(el('p', {class: 'together'},
1578
+ (t.context_window ? 'Prompts up to ' + tokens(t.context_window) + ' tokens' : 'Context window unknown') +
1579
+ (t.context_unknown.length ? ' (unknown for ' + t.context_unknown.join(', ') + '). ' : '. ') +
1580
+ (can.length ? 'All can ' + (can.length > 1 ? can.slice(0, -1).join(', ') + ' and ' : '') + can[can.length - 1] + '. '
1581
+ : 'Nothing known they can all do. ') +
1582
+ (lim.length ? 'Tightest limit ' + lim.join(', ') + ' across ' + n + '.' : 'No limits known across ' + n + '.')));
1439
1583
  }
1440
1584
  box.appendChild(left);
1441
1585
  box.appendChild(el('span', {class: 'open-hint', 'aria-hidden': 'true'}, 'Open ›'));
@@ -1532,6 +1676,290 @@
1532
1676
  }).catch(function (err) { $('v-errors').textContent = String(err); });
1533
1677
  });
1534
1678
 
1679
+ // ---- Quota: a tile per provider (serve.quota_tiles, metrics/quotaview.py) ----
1680
+ // Each tile takes its provider's shape: groups of windows (a 5-hour and a
1681
+ // weekly limit boxed together), one pool (OpenRouter's free requests a day,
1682
+ // a bar per key) or, where nothing is stated, each model's last day. A
1683
+ // meter is ten segments filled like a bar: 85% is eight and a half.
1684
+ var QD = $('quota-data') ? JSON.parse($('quota-data').textContent) : null;
1685
+ var qBox = $('q-tiles');
1686
+ function qWhen(iso) {
1687
+ if (!iso) return '';
1688
+ var d = new Date(iso), s = (d - Date.now()) / 1000;
1689
+ if (s <= 0) return 'now';
1690
+ if (s < 86400) {
1691
+ var h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
1692
+ return 'in ' + (h ? h + 'h ' : '') + m + 'm';
1693
+ }
1694
+ if (s < 6 * 86400) return d.toLocaleDateString([], {weekday: 'short'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
1695
+ return d.toLocaleDateString([], {month: 'numeric', day: 'numeric'}) + ' ' + d.toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
1696
+ }
1697
+ function qAgo(iso) {
1698
+ var s = (Date.now() - new Date(iso)) / 1000;
1699
+ return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' days ago';
1700
+ }
1701
+ function qTone(left) { return left >= 70 ? 'ok' : left >= 25 ? 'warn' : 'err'; }
1702
+ function qSegs(left) {
1703
+ var box = el('div', {class: 'segs ' + qTone(left), role: 'img', 'aria-label': left + '% left'});
1704
+ for (var i = 0; i < 10; i++) {
1705
+ var s = el('span'), f = el('i');
1706
+ f.style.width = Math.round(Math.min(Math.max(left / 10 - i, 0), 1) * 100) + '%';
1707
+ s.appendChild(f); box.appendChild(s);
1708
+ }
1709
+ return box;
1710
+ }
1711
+ function qLine(label, right) {
1712
+ var row = el('div', {class: 'q-line'});
1713
+ row.appendChild(el('span', {}, label));
1714
+ var r = el('span'); r.innerHTML = right; row.appendChild(r);
1715
+ return row;
1716
+ }
1717
+ function qNum(n) { return Number(n).toLocaleString(); }
1718
+ function qWindow(w) {
1719
+ var frag = document.createDocumentFragment();
1720
+ var row = qLine(w.label, '<b>' + w.left + '%</b>' + (w.reset_at ? ' · ' + qWhen(w.reset_at) : ''));
1721
+ if (w.of) row.title = qNum(w.remaining) + ' of ' + qNum(w.of) + ' left';
1722
+ frag.appendChild(row);
1723
+ frag.appendChild(qSegs(w.left));
1724
+ return frag;
1725
+ }
1726
+ function qResting(m) { return m.until && new Date(m.until) > Date.now(); }
1727
+ function qTile(t) {
1728
+ var node = el('article', {class: 'q-tile' + (t.out ? ' out' : ''), draggable: 'true', tabindex: '0',
1729
+ 'data-id': t.id, 'aria-label': t.name + ': drag, or use the arrow keys, to move it'});
1730
+ var head = el('div', {class: 'q-head'});
1731
+ head.appendChild(el('b', {class: 'nm'}, t.name));
1732
+ var f = t.fresh, how = f.how === 'live' ? 'read ' : f.how === 'seen' ? 'seen ' : 'answered ';
1733
+ head.appendChild(el('span', {class: 'chip ' + (f.how === 'live' ? 'ok' : 'via')}, f.at ? how + qAgo(f.at) : 'nothing yet'));
1734
+ node.appendChild(head);
1735
+ node.appendChild(el('div', {class: 'q-tag mono'}, t.tag + (t.plan ? ' · ' + t.plan : '')));
1736
+ if (t.kind === 'groups') {
1737
+ t.groups.forEach(function (g) {
1738
+ var box = el('section', {class: g.label ? 'q-group' : 'q-group bare'});
1739
+ if (g.label) box.appendChild(el('h4', {}, g.label));
1740
+ g.windows.forEach(function (w) { box.appendChild(qWindow(w)); });
1741
+ node.appendChild(box);
1742
+ });
1743
+ } else if (t.kind === 'pool') {
1744
+ t.pool.forEach(function (p) {
1745
+ var box = el('section', {class: 'q-group bare'});
1746
+ if (p.of) {
1747
+ box.appendChild(qLine(p.label, '<b>' + qNum(Math.max(0, p.of - p.used)) + '</b> of ' + qNum(p.of) + ' left'
1748
+ + (p.reset_at ? ' · ' + qWhen(p.reset_at) : '')));
1749
+ box.appendChild(qSegs(p.left));
1750
+ } else {
1751
+ box.appendChild(qLine(p.label, '<b>' + qNum(p.used) + '</b> sent · limit not known yet'));
1752
+ }
1753
+ node.appendChild(box);
1754
+ });
1755
+ } else {
1756
+ var shown = t.models.slice(0, 6);
1757
+ shown.forEach(function (m) {
1758
+ var right = qResting(m) ? '<span class="warn-t">resting · ' + qWhen(m.until) + '</span>'
1759
+ : m.ok + m.failed ? m.ok + ' ok · ' + m.failed + ' failed' : 'no calls today';
1760
+ var row = qLine('', right); row.firstChild.className = 'mono'; row.firstChild.textContent = m.alias;
1761
+ node.appendChild(row);
1762
+ });
1763
+ if (t.models.length > shown.length) node.appendChild(el('div', {class: 'muted q-small'}, '+ ' + (t.models.length - shown.length) + ' more in Models · last 24 h'));
1764
+ else node.appendChild(el('div', {class: 'muted q-small'}, 'last 24 h'));
1765
+ }
1766
+ if (t.kind !== 'models') {
1767
+ t.models.filter(qResting).forEach(function (m) {
1768
+ node.appendChild(el('div', {class: 'q-rest'}, m.alias + ' resting · back ' + qWhen(m.until)));
1769
+ });
1770
+ }
1771
+ if (t.conn_limit) node.appendChild(el('div', {class: 'muted q-small'}, t.conn_limit + ', set by you'));
1772
+ (t.notes || []).forEach(function (n) { node.appendChild(el('div', {class: 'muted q-small'}, n)); });
1773
+ var msg = el('div', {class: 'q-msg', 'aria-live': 'polite'});
1774
+ node.appendChild(msg);
1775
+ var foot = el('div', {class: 'q-foot'});
1776
+ var rb = el('button', {class: 'btn small', type: 'button', 'aria-label': 'Refresh ' + t.name,
1777
+ title: t.refresh === 'live' ? 'Ask the provider what is left (free)' : 'Read the bench\'s own records again (free)'});
1778
+ rb.innerHTML = '&#x21bb; Refresh';
1779
+ rb.addEventListener('click', function () { qAct('refresh', t.id, node); });
1780
+ foot.appendChild(rb);
1781
+ if (t.check) {
1782
+ var cb = el('button', {class: 'btn small', type: 'button', title: 'One short call to one of its models; its reply says what is left'}, 'Check now · 1 request');
1783
+ cb.addEventListener('click', function () { qAct('check', t.id, node); });
1784
+ foot.appendChild(cb);
1785
+ }
1786
+ var mb = el('button', {class: 'btn small', type: 'button'}, 'Models ');
1787
+ mb.appendChild(el('span', {class: 'chip ' + (t.live === t.bench ? 'ok' : t.live ? 'warn' : 'err')}, t.live + '/' + t.bench));
1788
+ mb.title = t.live + ' of the ' + t.bench + ' bench models on it are free to call';
1789
+ mb.addEventListener('click', function () { qModels(t); });
1790
+ foot.appendChild(mb);
1791
+ node.appendChild(foot);
1792
+ return node;
1793
+ }
1794
+ function qAct(which, id, node) {
1795
+ var msg = node.querySelector('.q-msg');
1796
+ msg.className = 'q-msg muted'; msg.textContent = which === 'check' ? 'Asking…' : 'Refreshing…';
1797
+ return post('/api/quota/' + which, {tag: id}).then(function (r) {
1798
+ if (!r.ok) { msg.className = 'q-msg errors'; msg.textContent = r.errors.join('; '); return; }
1799
+ var i = QD.tiles.findIndex(function (t) { return t.id === id; });
1800
+ if (r.tile && i >= 0) {
1801
+ QD.tiles[i] = r.tile;
1802
+ var fresh = qTile(r.tile);
1803
+ node.replaceWith(fresh);
1804
+ if (r.note) { var m = fresh.querySelector('.q-msg'); m.className = 'q-msg muted'; m.textContent = r.note; }
1805
+ }
1806
+ drawCharts();
1807
+ }).catch(function (e) { msg.className = 'q-msg errors'; msg.textContent = String(e); });
1808
+ }
1809
+ function qModels(t) {
1810
+ $('q-models-h').textContent = t.name + ': ' + t.bench + ' on the bench';
1811
+ $('q-models-sub').textContent = t.live + ' free to call now · the last 24 hours of calls';
1812
+ var list = $('q-models-list'); list.textContent = '';
1813
+ var tb = el('table'), hd = el('tr');
1814
+ ['Model', 'Now', 'Last 24 h', 'Last answered', 'Known limit'].forEach(function (h) { hd.appendChild(el('th', {}, h)); });
1815
+ tb.appendChild(el('thead')).appendChild(hd);
1816
+ var body = tb.appendChild(el('tbody'));
1817
+ t.models.forEach(function (m) {
1818
+ var tr = el('tr');
1819
+ tr.appendChild(el('td', {class: 'mono'}, m.alias));
1820
+ var now = el('td'); now.appendChild(el('span', {class: 'chip ' + (qResting(m) ? 'warn' : 'ok')}, qResting(m) ? 'resting · ' + qWhen(m.until) : 'ready'));
1821
+ tr.appendChild(now);
1822
+ tr.appendChild(el('td', {}, m.ok + ' ok · ' + m.failed + ' failed'));
1823
+ tr.appendChild(el('td', {}, m.last_ok ? qAgo(m.last_ok) : '—'));
1824
+ tr.appendChild(el('td', {}, m.limit || '—'));
1825
+ body.appendChild(tr);
1826
+ });
1827
+ list.appendChild(tb);
1828
+ $('q-models').showModal();
1829
+ }
1830
+ function qDefault(a, b) {
1831
+ return (b.out - a.out) || ((a.next_reset || '9999') < (b.next_reset || '9999') ? -1 : (a.next_reset || '9999') > (b.next_reset || '9999') ? 1 : 0)
1832
+ || (a.id < b.id ? -1 : 1);
1833
+ }
1834
+ function qDraw() {
1835
+ qBox.textContent = '';
1836
+ QD.tiles.forEach(function (t) { qBox.appendChild(qTile(t)); });
1837
+ if (!QD.tiles.length) qBox.appendChild(el('p', {class: 'muted'}, 'No model on the bench yet.'));
1838
+ }
1839
+ function qSaveOrder() {
1840
+ var ids = Array.prototype.map.call(qBox.querySelectorAll('.q-tile'), function (n) { return n.dataset.id; });
1841
+ var at = {}; ids.forEach(function (id, i) { at[id] = i; });
1842
+ QD.tiles.sort(function (a, b) { return at[a.id] - at[b.id]; });
1843
+ post('/api/quota/order', {order: ids}).then(function (r) {
1844
+ $('q-done').textContent = r.ok ? 'Order kept.' : ''; $('q-errors').textContent = r.ok ? '' : r.errors.join('; ');
1845
+ });
1846
+ }
1847
+ if (QD && qBox) {
1848
+ qDraw();
1849
+ $('q-models-close').addEventListener('click', function () { $('q-models').close(); });
1850
+ var dragged = null;
1851
+ qBox.addEventListener('dragstart', function (e) {
1852
+ dragged = e.target.closest && e.target.closest('.q-tile');
1853
+ if (!dragged) return;
1854
+ dragged.classList.add('dragging');
1855
+ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragged.dataset.id);
1856
+ });
1857
+ qBox.addEventListener('dragover', function (e) {
1858
+ if (!dragged) return;
1859
+ e.preventDefault();
1860
+ var over = e.target.closest && e.target.closest('.q-tile');
1861
+ if (!over || over === dragged) return;
1862
+ var r = over.getBoundingClientRect();
1863
+ var after = e.clientX > r.left + r.width / 2;
1864
+ over.parentNode.insertBefore(dragged, after ? over.nextSibling : over);
1865
+ });
1866
+ qBox.addEventListener('dragend', function () {
1867
+ if (!dragged) return;
1868
+ dragged.classList.remove('dragging'); dragged = null; qSaveOrder();
1869
+ });
1870
+ qBox.addEventListener('keydown', function (e) {
1871
+ var t = e.target;
1872
+ if (!t.classList || !t.classList.contains('q-tile')) return;
1873
+ var back = e.key === 'ArrowLeft' || e.key === 'ArrowUp', fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown';
1874
+ if (!back && !fwd) return;
1875
+ e.preventDefault();
1876
+ var sib = back ? t.previousElementSibling : t.nextElementSibling;
1877
+ if (!sib) return;
1878
+ qBox.insertBefore(t, back ? sib : sib.nextSibling);
1879
+ t.focus(); qSaveOrder();
1880
+ });
1881
+ $('q-reorder').addEventListener('click', function () {
1882
+ QD.tiles.sort(qDefault); qDraw();
1883
+ post('/api/quota/order', {order: []}).then(function (r) { $('q-done').textContent = r.ok ? 'Sorted: out first, then the soonest reset.' : ''; });
1884
+ });
1885
+ $('q-all').addEventListener('click', function () {
1886
+ var live = QD.tiles.filter(function (t) { return t.refresh === 'live'; });
1887
+ $('q-done').textContent = 'Refreshing ' + live.length + '…';
1888
+ live.reduce(function (p, t) {
1889
+ return p.then(function () {
1890
+ var node = qBox.querySelector('.q-tile[data-id="' + CSS.escape(t.id) + '"]');
1891
+ return node ? qAct('refresh', t.id, node) : null;
1892
+ });
1893
+ }, Promise.resolve()).then(function () { $('q-done').textContent = 'Refreshed ' + live.length + '.'; });
1894
+ });
1895
+ }
1896
+
1897
+ // The Quota page's two charts, in the theme's colours like the Benchmarks ones.
1898
+ function drawQuotaCharts(tok, style, width) {
1899
+ if (!QD) return;
1900
+ if (!window.Plot) {
1901
+ ['chart-sent', 'chart-resets'].forEach(function (id) {
1902
+ if ($(id)) $(id).textContent = 'Charts need Observable Plot from cdn.jsdelivr.net, which did not load.';
1903
+ });
1904
+ return;
1905
+ }
1906
+ var ink = tok('--ink', '#24331f'), good = tok('--ok', '#327a16'), warn = tok('--warn', '#7a4d00'), line = tok('--line', '#e4dfd2');
1907
+ var sent = $('chart-sent');
1908
+ if (sent) {
1909
+ var R = QD.sent.slice().sort(function (a, b) { return b.month - a.month; });
1910
+ sent.textContent = '';
1911
+ var lg = el('div', {class: 'q-legend'});
1912
+ [['This month, requests', 0.3], ['Last 24 hours, requests', 1]].forEach(function (k) {
1913
+ var s = el('span'), sw = el('i'); sw.style.background = good; sw.style.opacity = k[1];
1914
+ s.appendChild(sw); s.appendChild(document.createTextNode(k[0])); lg.appendChild(s);
1915
+ });
1916
+ sent.appendChild(lg);
1917
+ sent.appendChild(R.length ? Plot.plot({
1918
+ width: width(), height: 22 * R.length + 60, style: style, marginLeft: 120, marginRight: 90,
1919
+ x: {label: 'Requests →', grid: true}, y: {domain: R.map(function (r) { return r.provider; }), label: null},
1920
+ marks: [
1921
+ Plot.barX(R, {x: 'month', y: 'provider', fill: good, fillOpacity: 0.3,
1922
+ title: function (r) { return r.provider + '\nthis month: ' + qNum(r.month) + '\nlast 24 hours: ' + qNum(r.day); }}),
1923
+ Plot.barX(R, {x: 'day', y: 'provider', fill: good, insetTop: 4, insetBottom: 4}),
1924
+ Plot.text(R, {x: 'month', y: 'provider', text: function (r) { return qNum(r.day) + ' / ' + qNum(r.month); }, dx: 6, textAnchor: 'start', fill: ink}),
1925
+ Plot.ruleX([0], {stroke: ink})
1926
+ ]}) : el('p', {class: 'hint'}, 'No call in the ledger this month.'));
1927
+ }
1928
+ var res = $('chart-resets');
1929
+ if (res) {
1930
+ var now = Date.now(), end = now + 7 * 86400e3, dots = [], bars = [];
1931
+ QD.tiles.forEach(function (t) {
1932
+ var wins = [];
1933
+ t.groups.forEach(function (g) { g.windows.forEach(function (w) { wins.push({label: (g.label ? g.label + ' · ' : '') + w.label, at: w.reset_at}); }); });
1934
+ t.pool.forEach(function (p) { if (p.reset_at) wins.push({label: p.label, at: p.reset_at, daily: true}); });
1935
+ wins.forEach(function (w) {
1936
+ var at = +new Date(w.at), daily = w.daily || / day$/.test(w.label);
1937
+ for (; at && at <= end; at += 86400e3) {
1938
+ if (at >= now) dots.push({tile: t.name, at: new Date(at), what: w.label});
1939
+ if (!daily) break;
1940
+ }
1941
+ });
1942
+ t.models.filter(qResting).forEach(function (m) {
1943
+ bars.push({tile: t.name, x1: new Date(now), x2: new Date(Math.min(+new Date(m.until), end)), what: m.alias + ' resting until ' + new Date(m.until).toLocaleString()});
1944
+ });
1945
+ });
1946
+ var names = QD.tiles.map(function (t) { return t.name; }).filter(function (n) {
1947
+ return dots.some(function (d) { return d.tile === n; }) || bars.some(function (b) { return b.tile === n; });
1948
+ });
1949
+ res.textContent = '';
1950
+ res.appendChild(names.length ? Plot.plot({
1951
+ width: width(), height: 26 * names.length + 60, style: style, marginLeft: 120,
1952
+ x: {domain: [new Date(now), new Date(end)], label: null, grid: true}, y: {domain: names, label: null},
1953
+ marks: [
1954
+ Plot.barX(bars, {x1: 'x1', x2: 'x2', y: 'tile', fill: warn, fillOpacity: 0.35, insetTop: 6, insetBottom: 6, title: 'what'}),
1955
+ Plot.dot(dots, {x: 'at', y: 'tile', r: 4, fill: good, stroke: ink, strokeWidth: 0.5,
1956
+ title: function (d) { return d.what + ' resets ' + d.at.toLocaleString(); }}),
1957
+ Plot.ruleX([new Date(now)], {stroke: tok('--err', '#a12b2b'), strokeWidth: 1.5}),
1958
+ Plot.ruleY(names, {stroke: line, strokeOpacity: 0.5})
1959
+ ]}) : el('p', {class: 'hint'}, 'No reset or rest known for the next 7 days.'));
1960
+ }
1961
+ }
1962
+
1535
1963
  // ---- Benchmarks charts: Observable Plot, data from serve.charts_data ------
1536
1964
  // A function, not run once: a theme switched on Settings redraws them in its colours.
1537
1965
  function drawCharts() {
@@ -1554,6 +1982,7 @@
1554
1982
  var col = document.querySelector('.panes') || document.querySelector('main');
1555
1983
  return Math.max(520, (col.clientWidth || 900) - 50);
1556
1984
  }
1985
+ drawQuotaCharts(tok, style, width);
1557
1986
  function put(id, make) {
1558
1987
  var box = $(id);
1559
1988
  if (!box) return;
@@ -1737,6 +2166,71 @@
1737
2166
  });
1738
2167
  });
1739
2168
 
2169
+ // Vision tasks: one square per field and model, the task's name over its first field.
2170
+ put('chart-vision-fields', function (w) {
2171
+ var V = C.vision;
2172
+ if (!V.heat.length) return el('p', {class: 'hint'}, 'No vision task answers yet: the suite runs them for models'
2173
+ + ' that take images, at onboarding (Full) or through the scheduled passes.');
2174
+ // Grouped by where the pictures come from, the group's name over its first field.
2175
+ var order = ['made', 'own', 'receipts', 'screens', 'tables', 'objects'], keys = [], head = {};
2176
+ Object.keys(V.tasks).sort(function (a, b) {
2177
+ return order.indexOf(V.tasks[a].family) - order.indexOf(V.tasks[b].family) || (a < b ? -1 : 1);
2178
+ }).forEach(function (t) {
2179
+ var fam = V.tasks[t].family;
2180
+ V.tasks[t].fields.forEach(function (f) {
2181
+ if (!keys.some(function (k) { return head[k] === fam; })) head[t + '·' + f] = fam;
2182
+ keys.push(t + '·' + f);
2183
+ });
2184
+ });
2185
+ var S = V.summary;
2186
+ function share(m) { return S[m].fields ? S[m].right / S[m].fields : 0; }
2187
+ var models = Object.keys(S).sort(function (a, b) { return share(b) - share(a) || S[a].taken - S[b].taken; });
2188
+ var colour = {right: good, misread: signal, 'left out': line, 'trap taken': err, 'no answer': muted};
2189
+ function tip(d) { return d.model + '\n' + d.task.replace(/^vision-/, '') + ': ' + d.field + (d.trap ? ' (a trap)' : '') + '\n' + d.state; }
2190
+ return Plot.plot({
2191
+ // The group names tilted: a narrow group (a few tables) would run into its neighbour's.
2192
+ width: w, height: 22 * models.length + 140, style: style, marginLeft: 300, marginTop: 80, padding: 0.08,
2193
+ x: {domain: keys, axis: 'top', label: null, tickSize: 0, tickRotate: -60, textAnchor: 'start',
2194
+ tickFormat: function (k) { return head[k] || ''; }},
2195
+ y: {domain: models, label: null, tickSize: 0,
2196
+ tickFormat: function (m) { return m + ' (' + S[m].right + '/' + S[m].fields + ')'; }},
2197
+ color: {legend: true, domain: V.states, range: V.states.map(function (s) { return colour[s]; })},
2198
+ marks: [
2199
+ Plot.cell(V.heat, {x: 'key', y: 'model', fill: 'state', title: tip}),
2200
+ Plot.dot(V.heat.filter(function (d) { return d.trap; }), {x: 'key', y: 'model', r: 1.8, fill: ink, fillOpacity: 0.55}),
2201
+ Plot.ruleX(Object.keys(head), {x: function (k) { return k; }, stroke: ink, strokeOpacity: 0.25, dx: -2})
2202
+ ]
2203
+ });
2204
+ });
2205
+
2206
+ // Vision, real work: each model's seats on image panels by how they were judged.
2207
+ put('chart-vision-work', function (w) {
2208
+ var W = C.vision.work, kinds = C.vision.seatKinds, data = [], total = {};
2209
+ Object.keys(W).forEach(function (m) {
2210
+ 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]; });
2211
+ });
2212
+ if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
2213
+ // The models doing the most image work first: their bars are the evidence.
2214
+ var models = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
2215
+ var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
2216
+ 'no use, no claims': warn, 'not judged': line, 'no answer': muted};
2217
+ // The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
2218
+ var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
2219
+ return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
2220
+ });
2221
+ return Plot.plot({
2222
+ width: w, height: rows(models.length), style: style, marginLeft: 300, marginRight: 150,
2223
+ x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
2224
+ y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + total[m] + ')'; }},
2225
+ color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
2226
+ marks: [
2227
+ Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
2228
+ title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
2229
+ Plot.text(claims, {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
2230
+ ]
2231
+ });
2232
+ });
2233
+
1740
2234
  // Confidently wrong: stacked by kind, worst at the top.
1741
2235
  put('chart-wrong', function (w) {
1742
2236
  var T = C.wrongTotals;