@wendellresearch-ai/benchwarmers 0.6.2 → 0.6.3
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 +1 -1
- package/package.json +1 -1
- package/scripts/provider_tags.py +19 -3
- package/scripts/test_no_owui.py +8 -0
- package/serve.py +99 -37
- package/ui/bench.css +30 -8
- package/ui/bench.js +280 -110
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wendellresearch-ai/benchwarmers",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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/scripts/provider_tags.py
CHANGED
|
@@ -68,13 +68,29 @@ def get(hub: str, path: str) -> dict:
|
|
|
68
68
|
return json.load(response)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _host(url: str) -> str:
|
|
72
|
+
return (url or "").split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
|
|
73
|
+
|
|
74
|
+
|
|
71
75
|
def connection_names(hub: str) -> list[str]:
|
|
72
|
-
"""One short name per connection index. URLs only — never the keys.
|
|
76
|
+
"""One short name per connection index. URLs only — never the keys.
|
|
77
|
+
|
|
78
|
+
A provider reached both through the hub and on a direct endpoint of its
|
|
79
|
+
own (Cloudflare: chat through Open WebUI, decisions only direct) is ONE
|
|
80
|
+
connection: the hub's connection to that host takes the direct endpoint's
|
|
81
|
+
name, which is what its directly registered models are tagged (main). Any
|
|
82
|
+
provider, by host; other hubs (CLI Proxy API) are not direct endpoints."""
|
|
73
83
|
urls = get(hub, "/openai/config").get("OPENAI_API_BASE_URLS", [])
|
|
84
|
+
direct: dict[str, str] = {}
|
|
85
|
+
for name, entry in endpoints.load().items():
|
|
86
|
+
if name != hub and not endpoints.hub(name):
|
|
87
|
+
direct.setdefault(_host(entry.get("base", "")), name)
|
|
74
88
|
names = []
|
|
75
89
|
for url in urls:
|
|
76
|
-
host = url
|
|
77
|
-
if host.
|
|
90
|
+
host = _host(url)
|
|
91
|
+
if host in direct and host not in (endpoints.connections().get("hosts") or {}):
|
|
92
|
+
names.append(direct[host])
|
|
93
|
+
elif host.replace(".", "").isdigit():
|
|
78
94
|
# A BARE IP NAMES NOTHING, and guessing from it was worse than
|
|
79
95
|
# useless: a proxy labelled `lan-proxy` is described by where it
|
|
80
96
|
# sits rather than what it is, and Open WebUI does not expose
|
package/scripts/test_no_owui.py
CHANGED
|
@@ -236,6 +236,14 @@ def owner(fake: str) -> None:
|
|
|
236
236
|
assert tags["fast-1"] == "groq" and tags["llm7.codestral"] == "llm7-api" and tags["vendor/x:free"] == "openrouter", tags
|
|
237
237
|
assert "never-read" not in json.dumps(tags)
|
|
238
238
|
assert route.connection_of("fast-1") == "groq" and route.connection_of("x-free") == "openrouter"
|
|
239
|
+
# A provider also on a direct endpoint of its own is one connection: the hub's takes the endpoint's name.
|
|
240
|
+
real_load, both = endpoints.load, dict(endpoints.load())
|
|
241
|
+
both["groq-direct"] = {"base": "https://api.groq.com/openai/v1", "key": "groq-direct"}
|
|
242
|
+
endpoints.load = lambda *a, **k: both
|
|
243
|
+
try:
|
|
244
|
+
assert provider_tags.connection_names("openwebui") == ["groq-direct", "llm7-api"]
|
|
245
|
+
finally:
|
|
246
|
+
endpoints.load = real_load
|
|
239
247
|
# A decision model onboarded from the direct endpoint: in no registration, untagged, its `via` is its connection.
|
|
240
248
|
write("decision-models.json", {"dec-1": {"served": "vendor/decide:free", "endpoint": "openrouter", "via": "openrouter"},
|
|
241
249
|
"dec-hub": {"served": "hub-decide", "endpoint": "openwebui", "via": "openwebui"}})
|
package/serve.py
CHANGED
|
@@ -39,6 +39,7 @@ import datetime
|
|
|
39
39
|
import functools
|
|
40
40
|
import html
|
|
41
41
|
import json
|
|
42
|
+
import math
|
|
42
43
|
import os
|
|
43
44
|
import pathlib
|
|
44
45
|
import re
|
|
@@ -2639,7 +2640,7 @@ def charts_data() -> dict:
|
|
|
2639
2640
|
"rate": int(hit[1]) / max(1, int(hit[2])) if hit else None, "invented": bool(v["invented"])})
|
|
2640
2641
|
bars = [{"model": m, "kind": k, "rate": w / r["answers"], "n": f"{w}/{n}"}
|
|
2641
2642
|
for m, r in sorted(wr.items()) if m in live and r["answers"] for k, (w, n) in r["by"].items() if w]
|
|
2642
|
-
totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"]}
|
|
2643
|
+
totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"], "wrong": r["wrong"]}
|
|
2643
2644
|
for m, r in wr.items() if m in live and r["answers"]}
|
|
2644
2645
|
hunt = [{"model": m, **r["hunt"]} for m, r in sorted(wr.items()) if m in live and any(r["hunt"].values())]
|
|
2645
2646
|
return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
|
|
@@ -2900,16 +2901,10 @@ def vision_card() -> str:
|
|
|
2900
2901
|
|
|
2901
2902
|
|
|
2902
2903
|
def perf_charts() -> dict:
|
|
2903
|
-
"""
|
|
2904
|
-
|
|
2904
|
+
"""Speed on real tasks against how much a model finds, for the Performance
|
|
2905
|
+
panel of Benchmarks. (Whether probes answered is Uptime's.)"""
|
|
2905
2906
|
import route
|
|
2906
2907
|
live = on_bench()
|
|
2907
|
-
since = (datetime.date.today() - datetime.timedelta(days=14)).isoformat()
|
|
2908
|
-
outcomes: dict[tuple[str, str], int] = {}
|
|
2909
|
-
for r in perf_rows():
|
|
2910
|
-
if r["model"] in live and r.get("timestamp", "") >= since:
|
|
2911
|
-
o = "answered" if int(r.get("answered") or 0) > 0 else (r.get("failure") or "error")
|
|
2912
|
-
outcomes[(r["model"], o)] = outcomes.get((r["model"], o), 0) + 1
|
|
2913
2908
|
seconds: dict[str, list[float]] = {}
|
|
2914
2909
|
for (_task, m), v in suite_scores().items():
|
|
2915
2910
|
if m in live and re.match(r"\d+/\d+", v["found"]):
|
|
@@ -2917,8 +2912,7 @@ def perf_charts() -> dict:
|
|
|
2917
2912
|
recall = route.suite_recall()
|
|
2918
2913
|
speed = [{"model": m, "seconds": sorted(s)[len(s) // 2], "recall": recall[m][0] / recall[m][1], "tasks": len(s)}
|
|
2919
2914
|
for m, s in sorted(seconds.items()) if recall.get(m) and recall[m][1]]
|
|
2920
|
-
return {"
|
|
2921
|
-
"speed": speed}
|
|
2915
|
+
return {"speed": speed}
|
|
2922
2916
|
|
|
2923
2917
|
|
|
2924
2918
|
# ---- Trend: does a model score lower, or higher, than it used to? ----------
|
|
@@ -3545,9 +3539,15 @@ DECIDE_KIND = {"errors": "choice of 6", "collection": "choice of the collections
|
|
|
3545
3539
|
def decision_data() -> dict:
|
|
3546
3540
|
"""For bench.js: each model's calibration bins per set, [said, right, n]."""
|
|
3547
3541
|
res = decide.results()
|
|
3548
|
-
|
|
3542
|
+
models = sorted((res.get("models") or {}).items())
|
|
3543
|
+
return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS], "low": decide.MIN_ITEMS,
|
|
3544
|
+
# Right of asked per model and set: the bars, as the tables count it.
|
|
3545
|
+
"scores": [{"model": a, "set": s, "correct": r.get("correct", 0), "asked": r["asked"],
|
|
3546
|
+
"answered": r.get("answered", 0), "brier": r.get("brier")}
|
|
3547
|
+
for a, by in models for s, r in by.items()
|
|
3548
|
+
if s in decide.SETS and isinstance(r, dict) and r.get("asked")],
|
|
3549
3549
|
"points": [{"model": a, "set": s, "said": b["said"], "right": b["right"], "n": b["n"]}
|
|
3550
|
-
for a, by in
|
|
3550
|
+
for a, by in models for s, r in by.items()
|
|
3551
3551
|
if s in decide.SETS for b in r.get("calibration") or []]}
|
|
3552
3552
|
|
|
3553
3553
|
|
|
@@ -3614,8 +3614,7 @@ def decision_card() -> str:
|
|
|
3614
3614
|
extra = (f"<h3 class='chart-head'>Who each use asks now</h3><ul class='links'>{''.join(uses)}</ul>"
|
|
3615
3615
|
f"<p class='hint'>{'Measured up to ' + esc(when[:16].replace('T', ' ')) + '.' if when else 'Not measured yet.'}"
|
|
3616
3616
|
" The scheduler's <code>decisions</code> job asks each model one question a set a day;"
|
|
3617
|
-
" <code>python metrics/decide.py bench</code> runs a pass by hand.</p>"
|
|
3618
|
-
"<h3 class='chart-head'>Calibration: said against right</h3>")
|
|
3617
|
+
" <code>python metrics/decide.py bench</code> runs a pass by hand.</p>")
|
|
3619
3618
|
key = f"""<dl>
|
|
3620
3619
|
<dt>The sets</dt><dd>Questions with known answers, built from the bench's own data, no hand labelling.
|
|
3621
3620
|
<b>Error classes</b>: failed calls' error text (redacted: no keys, ids or emails) and the class the
|
|
@@ -3627,8 +3626,12 @@ def decision_card() -> str:
|
|
|
3627
3626
|
picked, so always saying the commonest option scores low.</dd>
|
|
3628
3627
|
<dt>Brier</dt><dd>How far its probabilities sat from the truth: 0 is perfect; for a choice it is
|
|
3629
3628
|
summed over the options, so up to 2. Lower is better, and a sure wrong answer costs most.</dd>
|
|
3630
|
-
<dt>
|
|
3631
|
-
|
|
3629
|
+
<dt>The bars</dt><dd>Right of asked for each model on each set, from 0%. A faint bar is a model
|
|
3630
|
+
under {low} items on that set: shown, not ranked yet. Hover a bar for its Brier.</dd>
|
|
3631
|
+
<dt>Calibration</dt><dd>The second chart, once a model answers at two or more levels of sureness
|
|
3632
|
+
on a set: answers grouped by the probability the model gave its answer
|
|
3633
|
+
(x), against how often those answers were right (y); both axes start just under the lowest
|
|
3634
|
+
answer, not at 0%. On the diagonal, its 80% answers come true
|
|
3632
3635
|
80% of the time; under it, it is surer than it should be. A bigger dot is more answers. The gap is
|
|
3633
3636
|
the average distance from the diagonal, weighted by answers.</dd>
|
|
3634
3637
|
<dt>At {decide.CONFIDENT}</dt><dd>The gateway acts on a choice only when the model is this sure: how
|
|
@@ -3669,26 +3672,45 @@ def bughunt_card() -> str:
|
|
|
3669
3672
|
grey said there was no bug.</dd>
|
|
3670
3673
|
<dt>Left of the line</dt><dd>The same files untouched: red is a false alarm, claiming a
|
|
3671
3674
|
bug in clean code. The longer the red, the less to trust its "there's a bug here".</dd>
|
|
3672
|
-
<dt>Order</dt><dd>
|
|
3675
|
+
<dt>Order</dt><dd>On what is proven, best at the top: the low end of the found share's likely
|
|
3676
|
+
range (95%) less the high end of the false alarms'. A few tasks give wide ranges, so they
|
|
3677
|
+
rank low; a model never shown clean code could be raising alarms on all of it.</dd>
|
|
3678
|
+
<dt>Faint bars</dt><dd>Under 10 tasks: shown, but little to go on.</dd>
|
|
3673
3679
|
</dl>"""
|
|
3674
3680
|
return chart_card("Bug hunt", "Every task with an answer key: bugs found against false alarms on the"
|
|
3675
3681
|
" same files left clean. Right is good, left is bad.", "chart-hunt", "".join(body), key)
|
|
3676
3682
|
|
|
3677
3683
|
|
|
3684
|
+
def _wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
|
3685
|
+
"""The 95% Wilson interval of k successes in n, as bench.js wilson() draws it."""
|
|
3686
|
+
if not n:
|
|
3687
|
+
return 0.0, 1.0
|
|
3688
|
+
p, z2 = k / n, z * z
|
|
3689
|
+
mid, half = p + z2 / (2 * n), z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
|
|
3690
|
+
return max(0.0, (mid - half) / (1 + z2 / n)), min(1.0, (mid + half) / (1 + z2 / n))
|
|
3691
|
+
|
|
3692
|
+
|
|
3678
3693
|
def work_card() -> str:
|
|
3679
3694
|
"""How often real offloads helped."""
|
|
3680
3695
|
rows = work_rows()
|
|
3681
3696
|
body = ["<table><thead><tr>", th("Model"), th("Offloads", True), th("Judged", True), th("Helped", True),
|
|
3682
|
-
th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
|
|
3683
|
-
for r in sorted(rows, key=lambda r: -r["useful"]
|
|
3697
|
+
th("Likely between", True), th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
|
|
3698
|
+
for r in sorted(rows, key=lambda r: (-_wilson(r["useful"], r["judged"])[0], -r["judged"])):
|
|
3699
|
+
lo, hi = _wilson(r["useful"], r["judged"])
|
|
3684
3700
|
body.append(f"<tr><td>{named(r['model'])}</td><td class='num'>{r['offloads']}</td><td class='num'>{r['judged']}</td>"
|
|
3685
3701
|
f"<td class='num' data-sort='{r['useful'] / r['judged']:.3f}'>{r['useful']}/{r['judged']}</td>"
|
|
3702
|
+
f"<td class='num' data-sort='{lo:.3f}'>{lo * 100:.0f}–{hi * 100:.0f}%</td>"
|
|
3686
3703
|
f"<td class='num'>{r['real']}</td><td class='num'>{r['false']}</td></tr>")
|
|
3687
3704
|
body.append("</tbody></table>")
|
|
3688
3705
|
key = """<dl>
|
|
3689
3706
|
<dt>Helped</dt><dd>Of the offloads judged, the share recorded useful: real work (reviews,
|
|
3690
3707
|
enumerations) handed to the model and checked afterwards. Benchmarks never count here.</dd>
|
|
3691
|
-
<dt>The
|
|
3708
|
+
<dt>The dot and its line</dt><dd>The dot is the share that helped, bigger for more judged. The
|
|
3709
|
+
line is where the true share likely sits (a 95% Wilson interval): long for 3 of 3, short for
|
|
3710
|
+
40 of 50. Green when even its low end is over half, red when even its high end is under,
|
|
3711
|
+
grey when there are too few to say.</dd>
|
|
3712
|
+
<dt>Order</dt><dd>By the low end of that line, so volume counts: 40 of 50 ranks above 3 of 3.</dd>
|
|
3713
|
+
<dt>The dashed line</dt><dd>Every model's judged offloads together, for comparison.</dd>
|
|
3692
3714
|
<dt>Findings and false claims</dt><dd>Verified one by one in reviews, recorded since 2026-10-17.</dd>
|
|
3693
3715
|
</dl>"""
|
|
3694
3716
|
return chart_card("Real work", "When a model was given real work and the result was checked: how often"
|
|
@@ -3698,7 +3720,8 @@ def work_card() -> str:
|
|
|
3698
3720
|
def overview_card() -> str:
|
|
3699
3721
|
return (f"<div class='panel'>{SHARE}<h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
|
|
3700
3722
|
" Right: finds more of the real bugs in our own code. Up: makes fewer things up (confidently"
|
|
3701
|
-
" wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be
|
|
3723
|
+
" wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be."
|
|
3724
|
+
" The models in or near the box, and the three that make most up, are named; hover for the rest.</p>"
|
|
3702
3725
|
"<div class='chart' id='chart-overview'></div></div>")
|
|
3703
3726
|
|
|
3704
3727
|
|
|
@@ -3811,6 +3834,10 @@ def wrong_card() -> str:
|
|
|
3811
3834
|
<dt>Reviews</dt><dd>False claims recorded in verdicts (<code>offload.py verdict|add
|
|
3812
3835
|
--false "..."</code>), over judged offloads. Recorded in structured form only since
|
|
3813
3836
|
2026-10-17, so older reviews show none.</dd>
|
|
3837
|
+
<dt>The thin line</dt><dd>Where the true share likely sits (95% range). Long for a few
|
|
3838
|
+
answers: 0 of 2 is not a clean record, and the line shows how bad it could be.</dd>
|
|
3839
|
+
<dt>Order</dt><dd>Worst proven at the top: by the low end of that line. Under 10 answers a
|
|
3840
|
+
model's bar is faint.</dd>
|
|
3814
3841
|
</dl>"""
|
|
3815
3842
|
return chart_card("Confidently wrong", "Who asserts things that are not true: false alarms on clean"
|
|
3816
3843
|
" code, wrong lines, false claims confirmed, traps taken. Shorter is better.",
|
|
@@ -3874,17 +3901,14 @@ def perf_card() -> str:
|
|
|
3874
3901
|
when it is slow enough to change how you work.</dd>
|
|
3875
3902
|
</dl>"""
|
|
3876
3903
|
key = key.replace("</dl>", """
|
|
3877
|
-
<dt>Did it answer?</dt><dd>
|
|
3878
|
-
rate limit (capacity), a timeout, or a failure the bench classifies (structural, paywalled,
|
|
3879
|
-
entitlement, other).</dd>
|
|
3904
|
+
<dt>Did it answer?</dt><dd>Is on Uptime, by day, with our own quota and setup problems left out.</dd>
|
|
3880
3905
|
<dt>Speed vs quality</dt><dd>Median seconds per answer on our own suite tasks (real prompts,
|
|
3881
|
-
not the one-word probe) against the share of findings located. Top left is fast and good
|
|
3906
|
+
not the one-word probe) against the share of findings located. Top left is fast and good:
|
|
3907
|
+
the green box is the faster half and the top quarter of findings.</dd>
|
|
3882
3908
|
</dl>""")
|
|
3883
3909
|
return chart_card("Performance", "What the serving path does — not what the models are worth. One"
|
|
3884
3910
|
" reading is weather; the history is climate.", "chart-speed", body, key,
|
|
3885
|
-
extra="<h3 class='chart-head'>
|
|
3886
|
-
"<div class='chart' id='chart-reliability'></div>"
|
|
3887
|
-
"<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
|
|
3911
|
+
extra="<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
|
|
3888
3912
|
|
|
3889
3913
|
|
|
3890
3914
|
def favicon(theme: str) -> str:
|
|
@@ -4300,13 +4324,21 @@ def collections_tab() -> str:
|
|
|
4300
4324
|
" the paid ones. Click a suggestion or type your own: comma-separated lower-case words, up to 8.</p>"
|
|
4301
4325
|
# Not data-filter: that binds to the whole panel. The same match (any
|
|
4302
4326
|
# text, case-insensitive), on the member list only; the seats show who is ticked.
|
|
4303
|
-
"<label class='field' for='c-member-filter'>Members</label>"
|
|
4327
|
+
"<div class='members-head'><label class='field' for='c-member-filter'>Members</label>"
|
|
4328
|
+
# Model details as they are now (a fact set in a model's popup, a refresh of the
|
|
4329
|
+
# catalog), the ticks and the search kept; also done when a model popup opened from here closes.
|
|
4330
|
+
"<button class='btn small' type='button' id='c-refresh' title='Fetch the models and their details again;"
|
|
4331
|
+
" what is ticked stays ticked'>Refresh model details</button>"
|
|
4332
|
+
"<span class='done' id='c-refresh-out' role='status'></span></div>"
|
|
4304
4333
|
# The panel as it will sit, drawn as the Collections list draws it
|
|
4305
4334
|
# (bench.js recount): a seat per ticked member, explorer seats dashed.
|
|
4306
4335
|
"<div id='c-seats' class='seats' aria-live='polite'></div>"
|
|
4307
4336
|
"<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
|
|
4308
4337
|
" autocomplete='off' placeholder='Filter by model, provider or served name' aria-controls='c-members'"
|
|
4309
|
-
" aria-describedby='c-count'><select id='c-member-
|
|
4338
|
+
" aria-describedby='c-count'><select id='c-member-show' aria-label='Show' aria-controls='c-members'>"
|
|
4339
|
+
"<option value=''>Every model</option><option value='ticked'>Ticked</option>"
|
|
4340
|
+
"<option value='fits'>Fits the tags</option></select>"
|
|
4341
|
+
"<select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
|
|
4310
4342
|
"<option value=''>Every provider</option></select></div>"
|
|
4311
4343
|
"<div id='c-members' class='members' role='group' aria-label='Members'></div>"
|
|
4312
4344
|
"<p class='rules' id='c-count'></p>"
|
|
@@ -5331,6 +5363,28 @@ def endpoints_page() -> str:
|
|
|
5331
5363
|
"<div class='done' id='l-done'></div></div>")
|
|
5332
5364
|
|
|
5333
5365
|
|
|
5366
|
+
def _reply_cell(by_prompt: dict) -> str:
|
|
5367
|
+
"""Models' Reply: the daily probe's median seconds to a one-word answer (tiny),
|
|
5368
|
+
with the 18 KB prompt's (large) and tokens a second on hover. Was on Benchmarks'
|
|
5369
|
+
Performance, beside whether it answered, which is Uptime's now."""
|
|
5370
|
+
def p50(r):
|
|
5371
|
+
try:
|
|
5372
|
+
return float(r["p50_s"]) if r and int(r.get("answered") or 0) > 0 else None
|
|
5373
|
+
except (KeyError, TypeError, ValueError):
|
|
5374
|
+
return None
|
|
5375
|
+
tiny, large = p50(by_prompt.get("tiny")), p50(by_prompt.get("large"))
|
|
5376
|
+
if tiny is None and large is None:
|
|
5377
|
+
return "<td class='num muted' data-sort='999999'>—</td>"
|
|
5378
|
+
shown = tiny if tiny is not None else large
|
|
5379
|
+
why = "; ".join(b for b in (
|
|
5380
|
+
f"one-word prompt {tiny:.1f}s" if tiny is not None else "",
|
|
5381
|
+
f"18 KB prompt {large:.1f}s" if large is not None else "",
|
|
5382
|
+
f"{(by_prompt.get('large') or {}).get('tokens_per_s')} tok/s"
|
|
5383
|
+
if (by_prompt.get("large") or {}).get("tokens_per_s") else "") if b)
|
|
5384
|
+
return (f"<td class='num' data-sort='{shown:.2f}' title='median, the daily probe: {esc(why)}'>"
|
|
5385
|
+
f"{shown:.1f}s</td>")
|
|
5386
|
+
|
|
5387
|
+
|
|
5334
5388
|
def models_page() -> str:
|
|
5335
5389
|
"""Every model on the bench by the connection it comes from."""
|
|
5336
5390
|
cards = []
|
|
@@ -5344,6 +5398,10 @@ def models_page() -> str:
|
|
|
5344
5398
|
# for what a collection may hold): they are on the bench like any other.
|
|
5345
5399
|
by: dict[str, list[str]] = {}
|
|
5346
5400
|
directory = model_directory()
|
|
5401
|
+
# The daily probe's latest reading per model and prompt (perf/history.csv), for Reply.
|
|
5402
|
+
speed: dict[str, dict] = {}
|
|
5403
|
+
for r in perf_rows():
|
|
5404
|
+
speed.setdefault(r["model"], {})[r.get("prompt", "")] = r
|
|
5347
5405
|
for a in directory:
|
|
5348
5406
|
by.setdefault(catalog.connection_of(a), []).append(a)
|
|
5349
5407
|
for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
|
|
@@ -5356,7 +5414,7 @@ def models_page() -> str:
|
|
|
5356
5414
|
kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
|
|
5357
5415
|
if a in decisions else "")
|
|
5358
5416
|
if directory[a].get("ad_hoc"):
|
|
5359
|
-
kind += " " + chip("ad hoc", "", "ad hoc only: never chosen by the bench on its own, only when you"
|
|
5417
|
+
kind += " " + chip("ad hoc", "adhoc", "ad hoc only: never chosen by the bench on its own, only when you"
|
|
5360
5418
|
" name it (a collection member, with, --model)")
|
|
5361
5419
|
rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
|
|
5362
5420
|
f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
|
|
@@ -5364,19 +5422,23 @@ def models_page() -> str:
|
|
|
5364
5422
|
f"<td class='num' data-sort='{f.get('context_window') or 0}'>{num(f.get('context_window'))}</td>"
|
|
5365
5423
|
f"<td class='num' data-sort='{f.get('max_output') or 0}'>{num(f.get('max_output'))}</td>"
|
|
5366
5424
|
f"<td>{esc(', '.join(f.get('input_modalities') or []) or '—')}</td>"
|
|
5425
|
+
+ _reply_cell(speed.get(a, {}))
|
|
5367
5426
|
+ "".join(f"<td class='num'>{num(lim.get(k))}</td>" for k in catalog.LIMIT_KEYS) + "</tr>")
|
|
5368
5427
|
# One column plan for every card, so the cards line up under each other
|
|
5369
5428
|
# (each table sized its own columns, so they drifted from card to card).
|
|
5370
5429
|
cols = "<colgroup><col class='c-model'><col class='c-via'><col class='c-n'><col class='c-n'>" \
|
|
5371
|
-
"<col class='c-input'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
|
|
5372
|
-
|
|
5430
|
+
"<col class='c-input'><col class='c-reply'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
|
|
5431
|
+
# Each connection folds; the page's one box filters them all (bench.js applyFilter, data-conn).
|
|
5432
|
+
cards.append(f"<details class='panel model-card' open data-conn='{esc(c['name'])}'><summary>"
|
|
5433
|
+
f"<h3 class='chart-head'>{esc(c['name'])} · {len(c['models'])} models</h3></summary>"
|
|
5373
5434
|
f"<table class='model-cards'>{cols}<thead><tr>" + th("Model") + th("Through")
|
|
5374
5435
|
+ th("Context", True) + th("Max output", True)
|
|
5375
|
-
+ th("Input") + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
|
|
5376
|
-
+ f"</tr></thead><tbody>{''.join(rows)}</tbody></table></
|
|
5436
|
+
+ th("Input") + th("Reply", True) + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
|
|
5437
|
+
+ f"</tr></thead><tbody>{''.join(rows)}</tbody></table></details>")
|
|
5377
5438
|
return ("<div class='panel'><h2>Models</h2><p class='sub'>Every model on the bench by the connection it"
|
|
5378
5439
|
" comes from, with the endpoint it is reached through and what is known of its limits. A name"
|
|
5379
|
-
" opens the model.</p
|
|
5440
|
+
" opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
|
|
5441
|
+
" placeholder='Find a model or connection' aria-label='Filter models'></div></div>"
|
|
5380
5442
|
+ "".join(cards))
|
|
5381
5443
|
|
|
5382
5444
|
|
package/ui/bench.css
CHANGED
|
@@ -293,10 +293,11 @@ td.num, th.num { text-align: right; }
|
|
|
293
293
|
table.model-cards { table-layout: fixed; min-width: 52rem; }
|
|
294
294
|
.panel:has(> table.model-cards) { overflow-x: auto; } /* scrolls on a narrow window instead of wrapping numbers */
|
|
295
295
|
table.model-cards td.num { white-space: nowrap; }
|
|
296
|
-
table.model-cards col.c-model { width:
|
|
297
|
-
table.model-cards col.c-via { width:
|
|
296
|
+
table.model-cards col.c-model { width: 23%; }
|
|
297
|
+
table.model-cards col.c-via { width: 11%; }
|
|
298
298
|
table.model-cards col.c-n { width: 10%; }
|
|
299
|
-
table.model-cards col.c-input { width:
|
|
299
|
+
table.model-cards col.c-input { width: 15%; }
|
|
300
|
+
table.model-cards col.c-reply { width: 7%; }
|
|
300
301
|
table.model-cards col.c-lim { width: 6%; }
|
|
301
302
|
table.model-cards td { overflow-wrap: anywhere; }
|
|
302
303
|
tbody tr:hover > td { background: var(--step-1); }
|
|
@@ -319,6 +320,7 @@ a.model:hover .alias { box-shadow: inset 0 -.3em 0 var(--signal); }
|
|
|
319
320
|
.chip.err { background: var(--err-ground); color: var(--err); }
|
|
320
321
|
.chip.new { background: var(--sky); color: var(--ink-deep); }
|
|
321
322
|
/* an endpoint a model is reached through: outlined, so it reads as a place, not a state */
|
|
323
|
+
.chip.adhoc { background: color-mix(in oklab, var(--sky) 55%, var(--paper-2)); color: var(--ink-deep); }
|
|
322
324
|
.chip.via { background: transparent; border: 1px solid var(--line); color: var(--ink); font-family: var(--font-mono); }
|
|
323
325
|
button.chip { font-family: inherit; border: 0; cursor: pointer; }
|
|
324
326
|
button.chip:hover { outline: 1px solid currentColor; }
|
|
@@ -334,6 +336,8 @@ td.why { white-space: nowrap; } /* Uptime's down / left out,
|
|
|
334
336
|
dialog { border: 1px solid var(--ink); border-radius: var(--radius); padding: 1.25rem 1.4rem;
|
|
335
337
|
width: min(40rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem);
|
|
336
338
|
background: var(--paper-2); color: var(--ink); box-shadow: 6px 6px 0 var(--ink); }
|
|
339
|
+
/* The collection popup holds the member table: wider, so names and chips sit on one line. */
|
|
340
|
+
dialog#c-form { width: min(54rem, calc(100vw - 2rem)); }
|
|
337
341
|
dialog::backdrop { background: color-mix(in oklab, var(--ink-deep) 45%, transparent); }
|
|
338
342
|
dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
339
343
|
:is(dialog, .form-panel) .served { color: var(--muted); font-size: .9375rem; margin: 0 0 .9rem; overflow-wrap: anywhere; }
|
|
@@ -344,11 +348,24 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
344
348
|
:is(dialog, .form-panel) .rules { font-size: .875rem; color: var(--muted); margin: .35rem 0 0; }
|
|
345
349
|
:is(dialog, .form-panel) label.check { display: flex; align-items: center; gap: .5rem; margin-top: 1rem; cursor: pointer; }
|
|
346
350
|
:is(dialog, .form-panel) fieldset { border: 0; padding: 0; margin: 1rem 0 0; }
|
|
347
|
-
:is(dialog, .form-panel) .members { max-height:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
351
|
+
:is(dialog, .form-panel) .members { max-height: min(30rem, 55vh); overflow: auto; border: 1px solid var(--line);
|
|
352
|
+
border-radius: var(--radius); }
|
|
353
|
+
/* A table to scan: the model (its name opens it), context, what it can do, then a
|
|
354
|
+
chip per provider it is on, each ticking that alias (bench.js drawMembers). */
|
|
355
|
+
:is(dialog, .form-panel) .members :is(.m-row, .m-head) { display: grid; gap: .2rem .8rem; align-items: center;
|
|
356
|
+
grid-template-columns: minmax(9rem, 1.4fr) 3.6rem minmax(0, 1fr) minmax(0, 1.3fr); padding: .4rem .6rem; }
|
|
357
|
+
:is(dialog, .form-panel) .members .m-head { position: sticky; top: 0; z-index: 1; background: var(--paper-2);
|
|
358
|
+
border-bottom: 1px solid var(--ink); font-size: .75rem; font-weight: 600; color: var(--muted); }
|
|
359
|
+
:is(dialog, .form-panel) .members .m-row { border-bottom: 1px solid var(--line); }
|
|
360
|
+
:is(dialog, .form-panel) .members .m-row:nth-child(odd) { background: color-mix(in oklab, var(--band) 45%, transparent); }
|
|
361
|
+
:is(dialog, .form-panel) .members .m-row:has(input:checked) { box-shadow: inset 3px 0 0 var(--bench-seat, var(--brand)); }
|
|
362
|
+
:is(dialog, .form-panel) .members .m-name a.model { color: var(--ink); text-decoration: none; font-size: .875rem; }
|
|
363
|
+
:is(dialog, .form-panel) .members .m-name a.model:hover { text-decoration: underline; }
|
|
364
|
+
:is(dialog, .form-panel) .members .m-ctx { font-family: var(--font-mono); font-size: .8125rem; color: var(--muted); text-align: right; }
|
|
365
|
+
:is(dialog, .form-panel) .members .m-can { display: flex; flex-wrap: wrap; gap: .2rem; }
|
|
366
|
+
:is(dialog, .form-panel) .members .m-can .chip { font-size: .6875rem; padding: 0 .4rem; }
|
|
367
|
+
#c-form .members-head { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
|
|
368
|
+
#c-form .members-head .field { margin-right: auto; }
|
|
352
369
|
:is(dialog, .form-panel) .members .m-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem;
|
|
353
370
|
font-size: .875rem; overflow-wrap: anywhere; }
|
|
354
371
|
:is(dialog, .form-panel) .members .m-facts { color: var(--muted); font-size: .8125rem; }
|
|
@@ -380,6 +397,8 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
|
|
|
380
397
|
:is(dialog, .form-panel) .profile .src { font-size: .75rem; color: var(--muted); }
|
|
381
398
|
:is(dialog, .form-panel) .settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1rem; }
|
|
382
399
|
@media (max-width: 40rem) { :is(dialog, .form-panel) :is(.settings, .members .m-row) { grid-template-columns: minmax(0, 1fr); }
|
|
400
|
+
:is(dialog, .form-panel) .members .m-head { display: none; }
|
|
401
|
+
:is(dialog, .form-panel) .members .m-ctx { text-align: left; }
|
|
383
402
|
:is(dialog, .form-panel) .members .m-routes { justify-content: flex-start; } }
|
|
384
403
|
:is(dialog, .form-panel) fieldset label.check { margin-top: .4rem; flex-wrap: wrap; }
|
|
385
404
|
:is(dialog, .form-panel) fieldset label.check .muted { font-size: .875rem; }
|
|
@@ -519,6 +538,9 @@ pre.log.answer { max-height: 40vh; }
|
|
|
519
538
|
.chart { margin: .5rem 0 .75rem; overflow-x: auto; }
|
|
520
539
|
.chart.muted { font-size: .875rem; }
|
|
521
540
|
h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
|
|
541
|
+
.chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
|
|
542
|
+
/* Models: each connection a fold, its heading the summary */
|
|
543
|
+
.model-card > summary h3.chart-head { display: inline; margin: 0; }
|
|
522
544
|
/* the Collections tab: the dialog's form, kept to a readable width */
|
|
523
545
|
.panel.form-panel > :not(#c-list, table) { max-width: 46rem; }
|
|
524
546
|
/* a list of things you open (collections), with its New button above */
|
package/ui/bench.js
CHANGED
|
@@ -253,13 +253,21 @@
|
|
|
253
253
|
var box = scope.querySelector('input[data-filter]'), q = box ? box.value.trim().toLowerCase() : '';
|
|
254
254
|
var facets = Array.prototype.filter.call(scope.querySelectorAll('select[data-facet]'), function (s) { return s.value; });
|
|
255
255
|
scope.querySelectorAll('tbody tr, .log-line').forEach(function (r) {
|
|
256
|
-
|
|
256
|
+
// A group's name (data-conn: Models' connection) counts as text of each of its rows.
|
|
257
|
+
var g = r.closest('[data-conn]'), text = r.textContent + (g ? ' ' + g.dataset.conn : '');
|
|
258
|
+
r.hidden = (q && text.toLowerCase().indexOf(q) === -1)
|
|
257
259
|
|| facets.some(function (s) { return r.dataset[s.dataset.facet] !== s.value; });
|
|
258
260
|
});
|
|
259
261
|
// A match inside a folded list (an endpoint's models) opens it.
|
|
260
262
|
if (q) scope.querySelectorAll('details.models').forEach(function (d) {
|
|
261
263
|
if (d.querySelector('tbody tr:not([hidden])')) d.open = true;
|
|
262
264
|
});
|
|
265
|
+
// A group with no row left is hidden; one with a match opens.
|
|
266
|
+
scope.querySelectorAll('[data-conn]').forEach(function (g) {
|
|
267
|
+
var any = !!g.querySelector('tbody tr:not([hidden])');
|
|
268
|
+
g.hidden = !!q && !any;
|
|
269
|
+
if (q && any && g.tagName === 'DETAILS') g.open = true;
|
|
270
|
+
});
|
|
263
271
|
}
|
|
264
272
|
function scopeOf(c) {
|
|
265
273
|
var box = c.closest('.toolbar') ? c.closest('.toolbar').querySelector('input[data-filter]') : c;
|
|
@@ -1880,7 +1888,7 @@
|
|
|
1880
1888
|
pick: $('g-pick'), pickConf: $('g-pick-conf'),
|
|
1881
1889
|
gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
|
|
1882
1890
|
del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
|
|
1883
|
-
seats: $('c-seats'), prov: $('c-member-provider'), adHoc: $('c-ad-hoc'),
|
|
1891
|
+
seats: $('c-seats'), prov: $('c-member-provider'), show: $('c-member-show'), adHoc: $('c-ad-hoc'),
|
|
1884
1892
|
ownCalls: $('c-max-calls'), ownExp: $('c-max-explorers')};
|
|
1885
1893
|
return loadCollections();
|
|
1886
1894
|
};
|
|
@@ -1994,7 +2002,7 @@
|
|
|
1994
2002
|
// the seats above the list always show what is picked, each with its ×, and
|
|
1995
2003
|
// every count still includes it. A row goes when all its chips do.
|
|
1996
2004
|
function filterMembers() {
|
|
1997
|
-
var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
|
|
2005
|
+
var q = c.find.value.trim().toLowerCase(), prov = c.prov.value, show = c.show.value;
|
|
1998
2006
|
c.members.querySelectorAll('.m-row').forEach(function (row) {
|
|
1999
2007
|
var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
|
|
2000
2008
|
row.querySelectorAll('label.check').forEach(function (lab) {
|
|
@@ -2002,7 +2010,8 @@
|
|
|
2002
2010
|
var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
|
|
2003
2011
|
lab.textContent).toLowerCase();
|
|
2004
2012
|
lab.hidden = !box.checked && !!lab.dataset.paid || !named && text.indexOf(q) === -1
|
|
2005
|
-
|| !!prov && (fact(box.value).provider || '') !== prov
|
|
2013
|
+
|| !!prov && (fact(box.value).provider || '') !== prov
|
|
2014
|
+
|| show === 'ticked' && !box.checked || show === 'fits' && !!box.dataset.blocked;
|
|
2006
2015
|
any = any || !lab.hidden;
|
|
2007
2016
|
});
|
|
2008
2017
|
row.hidden = !any;
|
|
@@ -2011,6 +2020,23 @@
|
|
|
2011
2020
|
}
|
|
2012
2021
|
on('input', 'c-member-filter', filterMembers);
|
|
2013
2022
|
on('change', 'c-member-provider', filterMembers);
|
|
2023
|
+
on('change', 'c-member-show', filterMembers);
|
|
2024
|
+
// Refresh model details: the models and their facts fetched again, the list drawn
|
|
2025
|
+
// anew with the same ticks, search and choices. The model popup's directory too.
|
|
2026
|
+
function refreshMembers() {
|
|
2027
|
+
var chosen = picked(), out = $('c-refresh-out'), btn = $('c-refresh');
|
|
2028
|
+
btn.disabled = true; out.textContent = 'Refreshing…';
|
|
2029
|
+
return Promise.all([get('/api/collections'), directory()]).then(function (res) {
|
|
2030
|
+
C = res[0];
|
|
2031
|
+
drawMembers(chosen);
|
|
2032
|
+
applyTags();
|
|
2033
|
+
out.textContent = 'Up to date.';
|
|
2034
|
+
}).catch(function (err) { out.textContent = String(err); })
|
|
2035
|
+
.then(function () { btn.disabled = false; });
|
|
2036
|
+
}
|
|
2037
|
+
on('click', 'c-refresh', refreshMembers);
|
|
2038
|
+
// A model opened from the list may have been changed in its popup: what it now is, on close.
|
|
2039
|
+
md.addEventListener('close', function () { if (c.form && c.form.open) refreshMembers(); });
|
|
2014
2040
|
// Escape in a box with text clears it; only an empty box lets Escape close the popup.
|
|
2015
2041
|
on('keydown', 'c-member-filter', function (e) {
|
|
2016
2042
|
if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
|
|
@@ -2059,13 +2085,37 @@
|
|
|
2059
2085
|
c.ownExp.value = row && row.max_explorers !== undefined ? row.max_explorers : '';
|
|
2060
2086
|
c.ownCalls.placeholder = C.settings.max_calls; c.ownExp.placeholder = C.settings.max_explorers;
|
|
2061
2087
|
drawTagPicks();
|
|
2062
|
-
c.
|
|
2088
|
+
c.find.value = ''; c.show.value = ''; c.prov.value = '';
|
|
2089
|
+
$('c-refresh-out').textContent = '';
|
|
2090
|
+
drawMembers(row ? row.members : []);
|
|
2091
|
+
c.shape.textContent = '';
|
|
2092
|
+
c.shape.appendChild(el('option', {value: ''}, 'automatic'));
|
|
2093
|
+
C.shapes.forEach(function (s) {
|
|
2094
|
+
var o = el('option', {value: s}, s); if (row && row.shape === s) o.selected = true; c.shape.appendChild(o);
|
|
2095
|
+
});
|
|
2096
|
+
c.reply.value = row ? row.reply : 'all';
|
|
2097
|
+
c.merger.textContent = '';
|
|
2098
|
+
c.merger.appendChild(el('option', {value: ''}, 'automatic'));
|
|
2099
|
+
C.models.forEach(function (alias) {
|
|
2100
|
+
var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
|
|
2101
|
+
if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
|
|
2102
|
+
});
|
|
2103
|
+
showMerger(); applyTags();
|
|
2104
|
+
}
|
|
2105
|
+
// The member list: a table to scan down, one row per model (named by its shortest
|
|
2106
|
+
// alias; the name opens its popup, as everywhere), its context and what it can do
|
|
2107
|
+
// in columns of their own, then a chip per provider it is on, each ticking that
|
|
2108
|
+
// alias. A chip whose window differs from the row's says its own. `chosen`: ticked.
|
|
2109
|
+
function drawMembers(chosen) {
|
|
2110
|
+
var keepProv = c.prov.value;
|
|
2111
|
+
c.members.textContent = '';
|
|
2063
2112
|
c.prov.length = 1; // "Every provider", then each one a model is on
|
|
2064
2113
|
Array.from(new Set(C.models.map(function (a) { return fact(a).provider; }).filter(Boolean))).sort()
|
|
2065
2114
|
.forEach(function (p) { c.prov.appendChild(el('option', {value: p}, p)); });
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2115
|
+
c.prov.value = keepProv;
|
|
2116
|
+
var head = el('div', {class: 'm-head', 'aria-hidden': 'true'});
|
|
2117
|
+
['Model', 'Context', 'Can', 'On, tick one'].forEach(function (h) { head.appendChild(el('span', null, h)); });
|
|
2118
|
+
c.members.appendChild(head);
|
|
2069
2119
|
var groups = {};
|
|
2070
2120
|
C.models.forEach(function (a) { var f = fact(a).family || a; (groups[f] = groups[f] || []).push(a); });
|
|
2071
2121
|
Object.keys(groups).map(function (f) {
|
|
@@ -2073,18 +2123,22 @@
|
|
|
2073
2123
|
return {family: f, name: routes[0], routes: routes};
|
|
2074
2124
|
}).sort(function (x, y) { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1; }).forEach(function (g) {
|
|
2075
2125
|
var first = fact(g.name), caps = first.caps || {};
|
|
2076
|
-
var can = ['vision', 'tools'].filter(function (k) { return caps[k]; });
|
|
2077
2126
|
var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
|
|
2078
2127
|
var name = el('div', {class: 'm-name'});
|
|
2079
|
-
name.appendChild(el('
|
|
2080
|
-
name.appendChild(el('span', {class: 'm-facts'}, [first.context ? tokens(first.context) : '', can.join(', '),
|
|
2081
|
-
first.price === 'unknown' ? '' : first.price].filter(Boolean).join(' · ')));
|
|
2128
|
+
name.appendChild(el('a', {href: '#', class: 'model mono', 'data-alias': g.name, title: 'Open ' + g.name}, g.name));
|
|
2082
2129
|
line.appendChild(name);
|
|
2130
|
+
line.appendChild(el('span', {class: 'm-ctx'}, first.context ? tokens(first.context) : '—'));
|
|
2131
|
+
var can = el('span', {class: 'm-can'});
|
|
2132
|
+
[['vision', 'vision'], ['tools', 'tools'], ['reasoning', 'reasons']].forEach(function (k) {
|
|
2133
|
+
if (caps[k[0]]) can.appendChild(el('span', {class: 'chip'}, k[1]));
|
|
2134
|
+
});
|
|
2135
|
+
if (first.price === 'free') can.appendChild(el('span', {class: 'chip ok'}, 'free'));
|
|
2136
|
+
line.appendChild(can);
|
|
2083
2137
|
var chips = el('div', {class: 'm-routes'});
|
|
2084
2138
|
g.routes.forEach(function (alias) {
|
|
2085
2139
|
var f = fact(alias);
|
|
2086
2140
|
var box = el('input', {type: 'checkbox', value: alias, 'aria-label': alias + ' on ' + (f.provider || 'its endpoint')});
|
|
2087
|
-
box.checked =
|
|
2141
|
+
box.checked = chosen.indexOf(alias) !== -1;
|
|
2088
2142
|
box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
|
|
2089
2143
|
var lab = el('label', {class: 'check', 'data-served': (C.served || {})[alias] || ''});
|
|
2090
2144
|
lab.appendChild(box);
|
|
@@ -2095,19 +2149,6 @@
|
|
|
2095
2149
|
line.appendChild(chips);
|
|
2096
2150
|
c.members.appendChild(line);
|
|
2097
2151
|
});
|
|
2098
|
-
c.shape.textContent = '';
|
|
2099
|
-
c.shape.appendChild(el('option', {value: ''}, 'automatic'));
|
|
2100
|
-
C.shapes.forEach(function (s) {
|
|
2101
|
-
var o = el('option', {value: s}, s); if (row && row.shape === s) o.selected = true; c.shape.appendChild(o);
|
|
2102
|
-
});
|
|
2103
|
-
c.reply.value = row ? row.reply : 'all';
|
|
2104
|
-
c.merger.textContent = '';
|
|
2105
|
-
c.merger.appendChild(el('option', {value: ''}, 'automatic'));
|
|
2106
|
-
C.models.forEach(function (alias) {
|
|
2107
|
-
var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
|
|
2108
|
-
if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
|
|
2109
|
-
});
|
|
2110
|
-
showMerger(); applyTags();
|
|
2111
2152
|
}
|
|
2112
2153
|
// What a collection's members can all do, in the words the tag checks use.
|
|
2113
2154
|
var CAN = {reasoning: 'reason'};
|
|
@@ -2660,7 +2701,8 @@
|
|
|
2660
2701
|
try { box.appendChild(make(width())); } catch (e) { box.textContent = 'Chart failed: ' + e; }
|
|
2661
2702
|
}
|
|
2662
2703
|
// Pixel offsets that keep scatter labels apart: each label, in order, moves
|
|
2663
|
-
// up or down until it clears the ones already placed
|
|
2704
|
+
// up or down until it clears the ones already placed; null when no place is
|
|
2705
|
+
// free (that one is left to its hover, not drawn over another).
|
|
2664
2706
|
function spread(points, xpx, ypx) {
|
|
2665
2707
|
var boxes = [], tries = [-12, 14, -26, 28, -40, 42, -54, 56];
|
|
2666
2708
|
return points.map(function (d) {
|
|
@@ -2672,7 +2714,7 @@
|
|
|
2672
2714
|
return tries[k];
|
|
2673
2715
|
}
|
|
2674
2716
|
}
|
|
2675
|
-
return
|
|
2717
|
+
return null;
|
|
2676
2718
|
});
|
|
2677
2719
|
}
|
|
2678
2720
|
function rows(n) { return 20 * n + 80; }
|
|
@@ -2682,6 +2724,16 @@
|
|
|
2682
2724
|
var keep = s.length - over - 1;
|
|
2683
2725
|
return over <= 0 ? s : s.slice(0, Math.ceil(keep / 2)) + '…' + s.slice(s.length - Math.floor(keep / 2));
|
|
2684
2726
|
}
|
|
2727
|
+
function fit(s, max) { return middle(s, s.length - max); }
|
|
2728
|
+
var FEW = 10; // under this many tasks or answers, a row is drawn faint
|
|
2729
|
+
// The 95% Wilson interval of k successes in n: [low, high]. Honest at small n and at
|
|
2730
|
+
// 0 or n of n, where k/n ± 1.96·se says nothing (3 of 3 is not "100% ± 0").
|
|
2731
|
+
function wilson(k, n) {
|
|
2732
|
+
if (!n) return [0, 1];
|
|
2733
|
+
var z = 1.96, p = k / n, z2 = z * z, den = 1 + z2 / n;
|
|
2734
|
+
var mid = p + z2 / (2 * n), half = z * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
|
|
2735
|
+
return [Math.max(0, (mid - half) / den), Math.min(1, (mid + half) / den)];
|
|
2736
|
+
}
|
|
2685
2737
|
|
|
2686
2738
|
// Trend: who moved, which way, by how much and how sure (serve.trend_data),
|
|
2687
2739
|
// in the window the card's select says. A row per model with a task to
|
|
@@ -2728,7 +2780,9 @@
|
|
|
2728
2780
|
var nameW = Math.min(260, Math.max(150, 7 * Math.max.apply(null, M.map(function (r) { return r.key.length; })) + 24));
|
|
2729
2781
|
var evW = 320;
|
|
2730
2782
|
w = $('chart-trend').clientWidth || w; // its own box: a phone's is narrower than width()'s 520
|
|
2731
|
-
|
|
2783
|
+
// Without the evidence column the names take what the bars do not need (260 px), not a
|
|
2784
|
+
// fixed 130 px: at the dashboard's width that cut "muse-glimmer-30b" to "muse-gl…mer-30b".
|
|
2785
|
+
if (w < nameW + evW + 260) { evW = 0; nameW = Math.min(nameW, Math.max(130, w - 280)); }
|
|
2732
2786
|
var fits = Math.floor((nameW - 24) / 7), right = evW || 20, wide = w < nameW + right + 140;
|
|
2733
2787
|
w = Math.max(w, nameW + right + 140);
|
|
2734
2788
|
// Symmetric, to the biggest bar and room for its number; 20 pts at least.
|
|
@@ -2830,16 +2884,74 @@
|
|
|
2830
2884
|
// the time; under it, it is surer than it should be. Panels stack on a phone.
|
|
2831
2885
|
var DC = C.decide;
|
|
2832
2886
|
if (DC) put('chart-decide', function (w) {
|
|
2833
|
-
if (!DC.
|
|
2887
|
+
if (!(DC.scores || []).length) return el('p', {class: 'hint'}, 'No decision model has answered a benchmark question yet.');
|
|
2834
2888
|
var names = {};
|
|
2835
2889
|
DC.sets.forEach(function (s) { names[s[0]] = s[1]; });
|
|
2836
2890
|
var panels = DC.sets.map(function (s) { return s[1]; });
|
|
2891
|
+
// One colour per model across both charts, by name, never by rank.
|
|
2892
|
+
var models = DC.scores.map(function (p) { return p.model; })
|
|
2893
|
+
.filter(function (m, i, all) { return all.indexOf(m) === i; }).sort();
|
|
2894
|
+
var colors = {domain: models, range: [sky, good, signal, warn, muted, ink, err]};
|
|
2895
|
+
var box = el('div');
|
|
2896
|
+
box.appendChild(el('h3', {class: 'chart-head'}, 'Right of asked, per set'));
|
|
2897
|
+
box.appendChild(decideBars(w, names, panels, models, colors));
|
|
2898
|
+
// Calibration only says something once a model's answers spread over two or more
|
|
2899
|
+
// levels of sureness; while each sits in one top bin it is one dot in a corner.
|
|
2900
|
+
var levels = DC.points.some(function (p, i, all) {
|
|
2901
|
+
return all.some(function (q) { return q !== p && q.model === p.model && q.set === p.set; });
|
|
2902
|
+
});
|
|
2903
|
+
if (levels) {
|
|
2904
|
+
box.appendChild(el('h3', {class: 'chart-head'}, 'Calibration: said against right'));
|
|
2905
|
+
box.appendChild(decideCalibration(w, names, panels, models, colors));
|
|
2906
|
+
} else {
|
|
2907
|
+
box.appendChild(el('p', {class: 'hint'}, 'Calibration shows once a model gives answers at two or more levels'
|
|
2908
|
+
+ ' of sureness on a set; so far each answers at one, so the table\'s Brier and calibration gap say it.'));
|
|
2909
|
+
}
|
|
2910
|
+
return box;
|
|
2911
|
+
});
|
|
2912
|
+
// Right of asked as bars from 0: a small chart per set, a row per model asked on it,
|
|
2913
|
+
// best first. A model under the ranking floor (DC.low items) is drawn faint: shown,
|
|
2914
|
+
// not yet ranked.
|
|
2915
|
+
function decideBars(w, names, panels, models, colors) {
|
|
2916
|
+
var out = el('div');
|
|
2917
|
+
DC.sets.forEach(function (s) {
|
|
2918
|
+
var rows = DC.scores.filter(function (r) { return r.set === s[0]; }).map(function (r) {
|
|
2919
|
+
return Object.assign({share: r.correct / r.asked, ranked: r.asked >= DC.low}, r);
|
|
2920
|
+
}).sort(function (a, b) { return b.share - a.share || b.asked - a.asked || (a.model < b.model ? -1 : 1); });
|
|
2921
|
+
out.appendChild(el('p', {class: 'chart-label'}, s[1]));
|
|
2922
|
+
if (!rows.length) { out.appendChild(el('p', {class: 'hint'}, 'No model asked on this set yet.')); return; }
|
|
2923
|
+
out.appendChild(Plot.plot({
|
|
2924
|
+
width: w, height: rows.length * 26 + 40, style: style,
|
|
2925
|
+
marginLeft: w < 560 ? 120 : 160, marginRight: 50, marginTop: 4, marginBottom: 30,
|
|
2926
|
+
x: {domain: [0, 1], label: null, tickFormat: '.0%', ticks: 5, grid: true},
|
|
2927
|
+
y: {domain: rows.map(function (r) { return r.model; }), label: null},
|
|
2928
|
+
color: colors,
|
|
2929
|
+
marks: [
|
|
2930
|
+
Plot.barX(rows, {x: 'share', y: 'model', fill: 'model', insetTop: 4, insetBottom: 4, rx: 2,
|
|
2931
|
+
fillOpacity: function (r) { return r.ranked ? 1 : 0.4; },
|
|
2932
|
+
title: function (r) {
|
|
2933
|
+
return r.model + ' · ' + s[1] + '\n' + r.correct + ' right of ' + r.asked + ' asked ('
|
|
2934
|
+
+ Math.round(r.share * 100) + '%)' + (r.brier != null ? '\nBrier ' + r.brier.toFixed(3) : '')
|
|
2935
|
+
+ (r.ranked ? '' : '\nnot ranked yet: under ' + DC.low + ' items');
|
|
2936
|
+
}}),
|
|
2937
|
+
Plot.text(rows, {x: 'share', y: 'model', dx: 6, textAnchor: 'start', fill: ink,
|
|
2938
|
+
text: function (r) { return r.correct + '/' + r.asked; }}),
|
|
2939
|
+
Plot.ruleX([0], {stroke: ink})
|
|
2940
|
+
]
|
|
2941
|
+
}));
|
|
2942
|
+
});
|
|
2943
|
+
return out;
|
|
2944
|
+
}
|
|
2945
|
+
function decideCalibration(w, names, panels, models, colors) {
|
|
2837
2946
|
var pts = DC.points.map(function (p) { return Object.assign({panel: names[p.set]}, p); })
|
|
2838
2947
|
.sort(function (a, b) { return a.said - b.said; });
|
|
2839
|
-
|
|
2840
|
-
|
|
2948
|
+
// Both axes start just under the lowest answer, not at 0%: a decision model is
|
|
2949
|
+
// rarely under 50% sure of its own answer, so a 0-100% frame left every dot in
|
|
2950
|
+
// one corner under a long empty diagonal. Same floor on both, so it stays 45°.
|
|
2951
|
+
var low = Math.min.apply(null, pts.map(function (p) { return Math.min(p.said, p.right); }));
|
|
2952
|
+
var lo = Math.max(0, Math.min(0.9, Math.floor((low - 0.05) * 20) / 20));
|
|
2841
2953
|
var diag = [];
|
|
2842
|
-
panels.forEach(function (s) { diag.push({panel: s, x:
|
|
2954
|
+
panels.forEach(function (s) { diag.push({panel: s, x: lo, y: lo}, {panel: s, x: 1, y: 1}); });
|
|
2843
2955
|
function tip(p) {
|
|
2844
2956
|
return p.model + ' · ' + names[p.set] + '\n' + plural(p.n, 'answer') + ' it was about '
|
|
2845
2957
|
+ Math.round(p.said * 100) + '% sure of\n' + Math.round(p.right * 100) + '% of them right';
|
|
@@ -2850,10 +2962,11 @@
|
|
|
2850
2962
|
return Plot.plot(Object.assign(opts, {
|
|
2851
2963
|
width: narrow ? Math.min(w, 520) : w, height: narrow ? 3 * 230 + 70 : 320, style: style,
|
|
2852
2964
|
marginLeft: 52, marginRight: narrow ? 90 : 20, marginBottom: 44, marginTop: 28,
|
|
2853
|
-
|
|
2854
|
-
|
|
2965
|
+
inset: 14, // a dot at 100% clear of the frame
|
|
2966
|
+
x: {domain: [lo, 1], label: 'Probability it gave its answer →', tickFormat: '.0%', ticks: 5, grid: true},
|
|
2967
|
+
y: {domain: [lo, 1], label: '↑ Share right', tickFormat: '.0%', ticks: 5, grid: true},
|
|
2855
2968
|
r: {range: [3, 13]},
|
|
2856
|
-
color: {legend: true,
|
|
2969
|
+
color: Object.assign({legend: true}, colors),
|
|
2857
2970
|
marks: [
|
|
2858
2971
|
Plot.frame({stroke: line}),
|
|
2859
2972
|
Plot.line(diag, Object.assign({x: 'x', y: 'y', z: 'panel', stroke: muted, strokeDasharray: '4 3'}, by)),
|
|
@@ -2862,17 +2975,24 @@
|
|
|
2862
2975
|
title: tip}, by))
|
|
2863
2976
|
]
|
|
2864
2977
|
}));
|
|
2865
|
-
}
|
|
2978
|
+
}
|
|
2866
2979
|
// Who to trust: finds real bugs (right) vs makes things up (up = less).
|
|
2867
2980
|
put('chart-overview', function (w) {
|
|
2868
2981
|
if (!C.overview.length) return el('p', {class: 'hint'}, 'No model has both suite scores and answers yet.');
|
|
2869
2982
|
var worst = Math.max(0.1, Math.max.apply(null, C.overview.map(function (d) { return d.wrong; })));
|
|
2870
2983
|
var x0 = Math.max(0, Math.floor((Math.min.apply(null, C.overview.map(function (d) { return d.recall; })) - 0.05) * 20) / 20);
|
|
2871
2984
|
var pxW = w - 110, pxH = 370;
|
|
2872
|
-
|
|
2985
|
+
// Named: the models in or next to the zone (the ones to pick from), then the clearest
|
|
2986
|
+
// warnings (most made up). The rest are on hover: named all at once they ran together.
|
|
2987
|
+
var zoneX = x0 + (1 - x0) / 2, zoneY = worst / 4;
|
|
2988
|
+
var near = function (d) { return d.recall >= zoneX - (1 - x0) * 0.08 && d.wrong <= zoneY * 1.4; };
|
|
2989
|
+
var worstFew = C.overview.slice().sort(function (a, b) { return b.wrong - a.wrong; }).slice(0, 3);
|
|
2990
|
+
var pts = C.overview.filter(function (d) { return near(d) || worstFew.indexOf(d) !== -1; })
|
|
2991
|
+
.sort(function (a, b) { return a.wrong - b.wrong || b.recall - a.recall; });
|
|
2873
2992
|
var offs = spread(pts, function (d) { return (d.recall - x0) / (1 - x0) * pxW; },
|
|
2874
2993
|
function (d) { return d.wrong / worst * pxH; });
|
|
2875
|
-
var labels = pts.map(function (d, i) { return {model: d.model, recall: d.recall, ty: d.wrong + offs[i] * worst / pxH}; })
|
|
2994
|
+
var labels = pts.map(function (d, i) { return offs[i] == null ? null : {model: d.model, recall: d.recall, ty: d.wrong + offs[i] * worst / pxH}; })
|
|
2995
|
+
.filter(Boolean);
|
|
2876
2996
|
return Plot.plot({
|
|
2877
2997
|
width: w, height: 440, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30,
|
|
2878
2998
|
x: {label: 'Finds real bugs in our code →', domain: [x0, 1], tickFormat: '.0%'},
|
|
@@ -2900,71 +3020,72 @@
|
|
|
2900
3020
|
tasks[h.model] = bugs + clean;
|
|
2901
3021
|
var f = bugs ? h.found / bugs : 0, wl = bugs ? h['wrong line'] / bugs : 0, ms = bugs ? h.missed / bugs : 0;
|
|
2902
3022
|
var fa = clean ? h['false alarm'] / clean : 0;
|
|
2903
|
-
|
|
3023
|
+
// Ranked on what is proven: the low end of the found share's 95% range less the HIGH
|
|
3024
|
+
// end of the false alarms' (never shown clean files: up to 100%), so a few tasks, or
|
|
3025
|
+
// none on clean code, cannot top the chart.
|
|
3026
|
+
score[h.model] = wilson(h.found, bugs)[0] - wilson(h['false alarm'], clean)[1];
|
|
2904
3027
|
segs.push({model: h.model, part: 'false alarm', x1: -fa, x2: 0, n: h['false alarm'] + ' of ' + clean + ' clean files'});
|
|
2905
3028
|
segs.push({model: h.model, part: 'found', x1: 0, x2: f, n: h.found + ' of ' + bugs + ' bugs'});
|
|
2906
3029
|
segs.push({model: h.model, part: 'wrong line', x1: f, x2: f + wl, n: h['wrong line'] + ' of ' + bugs + ' bugs'});
|
|
2907
3030
|
segs.push({model: h.model, part: 'missed', x1: f + wl, x2: f + wl + ms, n: h.missed + ' of ' + bugs + ' bugs'});
|
|
2908
3031
|
});
|
|
2909
|
-
var models = Object.keys(score).sort(function (a, b) { return score[b] - score[a]; });
|
|
3032
|
+
var models = Object.keys(score).sort(function (a, b) { return score[b] - score[a] || tasks[b] - tasks[a]; });
|
|
2910
3033
|
return Plot.plot({
|
|
2911
|
-
width: w, height: rows(models.length), style: style, marginLeft: 250,
|
|
3034
|
+
width: w, height: rows(models.length), style: style, marginLeft: w < 560 ? 150 : 250,
|
|
2912
3035
|
x: {domain: [-1, 1], label: '← false alarms on clean files · bugs found →', grid: true,
|
|
2913
3036
|
tickFormat: function (d) { return Math.abs(Math.round(d * 100)) + '%'; }},
|
|
2914
|
-
y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + tasks[m] + ')'; }},
|
|
3037
|
+
y: {domain: models, label: null, tickFormat: function (m) { return fit(m, w < 560 ? 16 : 28) + ' (' + tasks[m] + ')'; }},
|
|
2915
3038
|
color: {legend: true, domain: ['found', 'wrong line', 'missed', 'false alarm'], range: [good, signal, line, err]},
|
|
2916
3039
|
marks: [
|
|
3040
|
+
// Under FEW tasks a model's bars are faint: shown, not much to go on.
|
|
2917
3041
|
Plot.barX(segs.filter(function (s) { return s.x2 > s.x1; }), {x1: 'x1', x2: 'x2', y: 'model', fill: 'part',
|
|
2918
|
-
|
|
3042
|
+
fillOpacity: function (d) { return tasks[d.model] < FEW ? 0.4 : 1; },
|
|
3043
|
+
title: function (d) { return d.model + ': ' + d.part + ', ' + d.n + (tasks[d.model] < FEW ? '\nfew tasks: little to go on' : ''); }}),
|
|
2919
3044
|
Plot.ruleX([0], {stroke: ink, strokeWidth: 1.5})
|
|
2920
3045
|
]
|
|
2921
3046
|
});
|
|
2922
3047
|
});
|
|
2923
3048
|
|
|
2924
|
-
// Real work:
|
|
3049
|
+
// Real work: how often each model helped, with how sure that is. The dot is the share
|
|
3050
|
+
// helped, its size how many were judged, the line the range the true share likely sits
|
|
3051
|
+
// in (95% Wilson interval: wide for 3 of 3, narrow for 40 of 50). Ranked by the low end
|
|
3052
|
+
// of that range, so volume counts: 3 of 3 no longer outranks 40 of 50. The dashed line
|
|
3053
|
+
// is every model's offloads pooled.
|
|
2925
3054
|
put('chart-work', function (w) {
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
3055
|
+
if (!C.work.length) return el('p', {class: 'hint'}, 'No judged offloads yet.');
|
|
3056
|
+
var R = C.work.map(function (r) {
|
|
3057
|
+
var b = wilson(r.useful, r.judged), p = r.useful / r.judged;
|
|
3058
|
+
var verdict = b[0] >= 0.5 ? 'helps' : b[1] < 0.5 ? 'does not help' : 'too few to say';
|
|
3059
|
+
return Object.assign({p: p, lo: b[0], hi: b[1], verdict: verdict}, r);
|
|
3060
|
+
}).sort(function (a, b) { return b.lo - a.lo || b.judged - a.judged; });
|
|
3061
|
+
var all = R.reduce(function (s, r) { return [s[0] + r.useful, s[1] + r.judged]; }, [0, 0]);
|
|
3062
|
+
var pooled = all[0] / all[1];
|
|
3063
|
+
var narrowW = w < 560, nameW = narrowW ? 130 : 210;
|
|
2931
3064
|
return Plot.plot({
|
|
2932
|
-
width: w, height:
|
|
2933
|
-
x: {domain: [0, 1], label: 'Helped, share of judged offloads →', tickFormat: '.0%', grid: true},
|
|
3065
|
+
width: w, height: 24 * R.length + 90, style: style, marginLeft: nameW, marginRight: 64, marginTop: 34,
|
|
3066
|
+
x: {domain: [0, 1], label: 'Helped, share of judged offloads →', tickFormat: '.0%', ticks: 5, grid: true},
|
|
2934
3067
|
y: {domain: R.map(function (r) { return r.model; }), label: null,
|
|
2935
|
-
tickFormat: function (m) { return m
|
|
3068
|
+
tickFormat: function (m) { return fit(m, narrowW ? 16 : 26); }},
|
|
3069
|
+
r: {domain: [0, Math.max.apply(null, R.map(function (r) { return r.judged; }))], range: [3, 11]},
|
|
3070
|
+
color: {domain: ['helps', 'too few to say', 'does not help'], range: [good, muted, err], legend: true},
|
|
2936
3071
|
marks: [
|
|
2937
|
-
Plot.
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
Plot.
|
|
3072
|
+
Plot.ruleX([0.5], {stroke: line}),
|
|
3073
|
+
Plot.ruleX([pooled], {stroke: ink, strokeDasharray: '4 3', strokeOpacity: 0.6}),
|
|
3074
|
+
Plot.text([pooled], {x: function (d) { return d; }, frameAnchor: 'top', dy: -20, fill: muted, fontSize: 11,
|
|
3075
|
+
text: function () { return 'all models ' + Math.round(pooled * 100) + '%'; }}),
|
|
3076
|
+
Plot.ruleY(R, {y: 'model', x1: 'lo', x2: 'hi', stroke: 'verdict', strokeWidth: 2, strokeOpacity: 0.55}),
|
|
3077
|
+
Plot.dot(R, {x: 'p', y: 'model', r: 'judged', fill: 'verdict', stroke: halo, strokeWidth: 1.5,
|
|
3078
|
+
title: function (r) {
|
|
3079
|
+
return r.model + '\nhelped ' + r.useful + ' of ' + r.judged + ' judged (' + Math.round(r.p * 100) + '%; '
|
|
3080
|
+
+ r.offloads + ' offloads)\nlikely between ' + Math.round(r.lo * 100) + '% and ' + Math.round(r.hi * 100) + '%'
|
|
3081
|
+
+ (r.real || r.false ? '\nverified findings ' + r.real + ', false claims ' + r.false : '');
|
|
3082
|
+
}}),
|
|
3083
|
+
Plot.text(R, {x: 'hi', y: 'model', dx: 8, textAnchor: 'start', fill: ink, fontSize: 11,
|
|
3084
|
+
text: function (r) { return r.useful + '/' + r.judged; }})
|
|
2942
3085
|
]
|
|
2943
3086
|
});
|
|
2944
3087
|
});
|
|
2945
3088
|
|
|
2946
|
-
// Did it answer: each probe of the last 14 days, by what came back.
|
|
2947
|
-
put('chart-reliability', function (w) {
|
|
2948
|
-
if (!C.reliability.length) return el('p', {class: 'hint'}, 'No probes in the last 14 days.');
|
|
2949
|
-
var total = {}, ok = {};
|
|
2950
|
-
C.reliability.forEach(function (d) {
|
|
2951
|
-
total[d.model] = (total[d.model] || 0) + d.count;
|
|
2952
|
-
if (d.outcome === 'answered') ok[d.model] = d.count;
|
|
2953
|
-
});
|
|
2954
|
-
var models = Object.keys(total).sort(function (a, b) { return (ok[b] || 0) / total[b] - (ok[a] || 0) / total[a]; });
|
|
2955
|
-
var colour = {answered: good, capacity: signal, timeout: sky, structural: err, paywalled: warn,
|
|
2956
|
-
entitlement: '#c0563f', other: muted, error: ink};
|
|
2957
|
-
var order = Object.keys(colour).filter(function (o) { return C.reliability.some(function (d) { return d.outcome === o; }); });
|
|
2958
|
-
return Plot.plot({
|
|
2959
|
-
width: w, height: rows(models.length), style: style, marginLeft: 250,
|
|
2960
|
-
x: {label: 'Share of probes →', tickFormat: '.0%', grid: true},
|
|
2961
|
-
y: {domain: models, label: null},
|
|
2962
|
-
color: {legend: true, domain: order, range: order.map(function (o) { return colour[o]; })},
|
|
2963
|
-
marks: [Plot.barX(C.reliability, Plot.stackX({x: 'count', y: 'model', fill: 'outcome', offset: 'normalize', order: order,
|
|
2964
|
-
title: function (d) { return d.model + ': ' + d.outcome + ' ' + d.count + ' of ' + total[d.model] + ' probes'; }}))]
|
|
2965
|
-
});
|
|
2966
|
-
});
|
|
2967
|
-
|
|
2968
3089
|
// Speed vs quality: median seconds on our own tasks against findings located.
|
|
2969
3090
|
put('chart-speed', function (w) {
|
|
2970
3091
|
if (!C.speed.length) return el('p', {class: 'hint'}, 'No suite timings yet.');
|
|
@@ -2975,14 +3096,20 @@
|
|
|
2975
3096
|
var pts = C.speed.slice().sort(function (a, b) { return b.recall - a.recall; });
|
|
2976
3097
|
var offs = spread(pts, function (d) { return (Math.log(d.seconds) - Math.log(lo)) / (Math.log(hi) - Math.log(lo)) * pxW; },
|
|
2977
3098
|
function (d) { return (1 - d.recall) / (1 - y0) * pxH; });
|
|
2978
|
-
var labels = pts.map(function (d, i) { return {model: d.model, seconds: d.seconds, ty: d.recall - offs[i] * (1 - y0) / pxH}; })
|
|
3099
|
+
var labels = pts.map(function (d, i) { return offs[i] == null ? null : {model: d.model, seconds: d.seconds, ty: d.recall - offs[i] * (1 - y0) / pxH}; })
|
|
3100
|
+
.filter(Boolean);
|
|
2979
3101
|
return Plot.plot({
|
|
2980
3102
|
width: w, height: 420, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30,
|
|
2981
3103
|
x: {type: 'log', domain: [lo, hi], label: 'Median seconds per answer on our tasks →'},
|
|
2982
3104
|
y: {domain: [y0, 1], label: '↑ Findings located', tickFormat: '.0%'},
|
|
2983
3105
|
marks: [
|
|
3106
|
+
// The zone to be in, as on Who to trust: the faster half (on the log scale) and the
|
|
3107
|
+
// top quarter of findings. Outlined, so it reads on a dark ground too.
|
|
3108
|
+
Plot.rect([{x1: lo, x2: Math.sqrt(lo * hi), y1: y0 + (1 - y0) * 3 / 4, y2: 1}], {x1: 'x1', x2: 'x2', y1: 'y1', y2: 'y2',
|
|
3109
|
+
fill: good, fillOpacity: 0.12, stroke: good, strokeOpacity: 0.5, strokeDasharray: '4 3'}),
|
|
2984
3110
|
Plot.dot(C.speed, {x: 'seconds', y: 'recall', r: 6, fill: sky, stroke: ink, strokeWidth: 0.5}),
|
|
2985
|
-
Plot.text(labels, {x: 'seconds', y: 'ty', text: 'model', fontSize: 11, fill: ink
|
|
3111
|
+
Plot.text(labels, {x: 'seconds', y: 'ty', text: 'model', fontSize: 11, fill: ink,
|
|
3112
|
+
stroke: halo, strokeWidth: 3.5, strokeOpacity: 0.9, paintOrder: 'stroke'}),
|
|
2986
3113
|
Plot.tip(C.speed, Plot.pointer({x: 'seconds', y: 'recall',
|
|
2987
3114
|
title: function (d) { return d.model + '\nmedian ' + d.seconds.toFixed(0) + 's over ' + d.tasks + ' tasks\nfinds ' + Math.round(d.recall * 100) + '%'; }}))
|
|
2988
3115
|
]
|
|
@@ -2997,25 +3124,41 @@
|
|
|
2997
3124
|
return C.families.indexOf(fam[a]) - C.families.indexOf(fam[b]) || (a < b ? -1 : 1);
|
|
2998
3125
|
});
|
|
2999
3126
|
tasks.forEach(function (t) { if (!(fam[t] in first)) first[fam[t]] = t; });
|
|
3000
|
-
|
|
3001
|
-
|
|
3127
|
+
// Each group's name over its first task, on two staggered rows: side by side they
|
|
3128
|
+
// ran into one another wherever a group is only a few tasks wide.
|
|
3129
|
+
var heads = C.families.filter(function (f) { return f in first; })
|
|
3130
|
+
.map(function (f, i) { return {task: first[f], name: f, row: i % 2}; });
|
|
3002
3131
|
// By total found, so a model that played three tasks does not top one that played forty.
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3132
|
+
// A model with no scored task (only grey) gets no row: it is named under the chart.
|
|
3133
|
+
var sum = {}, scored = {};
|
|
3134
|
+
C.heat.forEach(function (d) {
|
|
3135
|
+
sum[d.model] = (sum[d.model] || 0) + (d.rate || 0);
|
|
3136
|
+
if (d.rate != null) scored[d.model] = true;
|
|
3137
|
+
});
|
|
3138
|
+
var models = Object.keys(sum).filter(function (m) { return scored[m]; }).sort(function (a, b) { return sum[b] - sum[a]; });
|
|
3139
|
+
var idle = Object.keys(sum).filter(function (m) { return !scored[m]; }).sort();
|
|
3140
|
+
var shown = C.heat.filter(function (d) { return scored[d.model]; });
|
|
3141
|
+
var nameW = w < 560 ? 130 : 230;
|
|
3006
3142
|
function tip(d) { return d.model + '\n' + d.task + ' (' + d.family + ')\n' + d.label + (d.invented ? '\ninvented something' : ''); }
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3143
|
+
var box = el('div');
|
|
3144
|
+
box.appendChild(Plot.plot({
|
|
3145
|
+
width: w, height: 22 * models.length + 84, style: style, marginLeft: nameW, marginTop: 44, padding: 0.08,
|
|
3146
|
+
x: {domain: tasks, axis: null},
|
|
3147
|
+
y: {domain: models, label: null, tickSize: 0, tickFormat: function (m) { return fit(m, w < 560 ? 16 : 30); }},
|
|
3011
3148
|
color: {type: 'linear', domain: [0, 0.5, 1], range: [err, signal, good]},
|
|
3012
3149
|
marks: [
|
|
3013
|
-
Plot.cell(
|
|
3014
|
-
Plot.cell(
|
|
3015
|
-
Plot.dot(
|
|
3016
|
-
Plot.ruleX(
|
|
3150
|
+
Plot.cell(shown.filter(function (d) { return d.rate == null; }), {x: 'task', y: 'model', fill: line, title: tip}),
|
|
3151
|
+
Plot.cell(shown.filter(function (d) { return d.rate != null; }), {x: 'task', y: 'model', fill: 'rate', title: tip}),
|
|
3152
|
+
Plot.dot(shown.filter(function (d) { return d.invented; }), {x: 'task', y: 'model', r: 2.4, fill: ink}),
|
|
3153
|
+
Plot.ruleX(heads, {x: 'task', stroke: ink, strokeOpacity: 0.25, dx: -2}),
|
|
3154
|
+
Plot.text(heads.filter(function (h) { return !h.row; }), {x: 'task', frameAnchor: 'top', dy: -30, dx: -2,
|
|
3155
|
+
text: 'name', textAnchor: 'start', fontSize: 11, fill: ink}),
|
|
3156
|
+
Plot.text(heads.filter(function (h) { return h.row; }), {x: 'task', frameAnchor: 'top', dy: -14, dx: -2,
|
|
3157
|
+
text: 'name', textAnchor: 'start', fontSize: 11, fill: ink})
|
|
3017
3158
|
]
|
|
3018
|
-
});
|
|
3159
|
+
}));
|
|
3160
|
+
if (idle.length) box.appendChild(el('p', {class: 'hint'}, 'No scored task yet, so no row: ' + idle.join(', ') + '.'));
|
|
3161
|
+
return box;
|
|
3019
3162
|
});
|
|
3020
3163
|
|
|
3021
3164
|
// Vision tasks: one square per field and model, the task's name over its first field.
|
|
@@ -3062,39 +3205,66 @@
|
|
|
3062
3205
|
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]; });
|
|
3063
3206
|
});
|
|
3064
3207
|
if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
|
|
3065
|
-
// The models doing the most image work first: their bars are the evidence.
|
|
3066
|
-
|
|
3208
|
+
// The models doing the most image work first: their bars are the evidence. Under FEW
|
|
3209
|
+
// seats a row was mostly "no answer" and said little: those are listed under the chart.
|
|
3210
|
+
var all = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
|
|
3211
|
+
var models = all.filter(function (m) { return total[m] >= FEW; });
|
|
3212
|
+
var few = all.filter(function (m) { return total[m] < FEW; });
|
|
3213
|
+
if (!models.length) { models = all; few = []; }
|
|
3214
|
+
data = data.filter(function (d) { return models.indexOf(d.model) !== -1; });
|
|
3067
3215
|
var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
|
|
3068
3216
|
'no use, no claims': warn, 'not judged': line, 'no answer': muted};
|
|
3069
3217
|
// The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
|
|
3070
3218
|
var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
|
|
3071
3219
|
return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
|
|
3072
3220
|
});
|
|
3073
|
-
|
|
3074
|
-
|
|
3221
|
+
var narrowV = w < 560, box = el('div');
|
|
3222
|
+
box.appendChild(Plot.plot({
|
|
3223
|
+
width: w, height: rows(models.length), style: style, marginLeft: narrowV ? 150 : 260, marginRight: narrowV ? 20 : 150,
|
|
3075
3224
|
x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
|
|
3076
|
-
y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + total[m] + ')'; }},
|
|
3225
|
+
y: {domain: models, label: null, tickFormat: function (m) { return fit(m, narrowV ? 16 : 30) + ' (' + total[m] + ')'; }},
|
|
3077
3226
|
color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
|
|
3078
3227
|
marks: [
|
|
3079
3228
|
Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
|
|
3080
3229
|
title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
|
|
3081
|
-
Plot.text(
|
|
3230
|
+
Plot.text(narrowV ? [] : claims.filter(function (c) { return models.indexOf(c.model) !== -1; }),
|
|
3231
|
+
{x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
|
|
3082
3232
|
]
|
|
3083
|
-
});
|
|
3233
|
+
}));
|
|
3234
|
+
if (few.length) box.appendChild(el('p', {class: 'hint'}, 'Under ' + FEW + ' seats, so no bar: '
|
|
3235
|
+
+ few.map(function (m) { return m + ' (' + total[m] + ')'; }).join(', ') + '. The table has them all.'));
|
|
3236
|
+
return box;
|
|
3084
3237
|
});
|
|
3085
3238
|
|
|
3086
|
-
// Confidently wrong: stacked by kind
|
|
3239
|
+
// Confidently wrong: stacked by kind. A thin line runs over each bar for the range the
|
|
3240
|
+
// true share likely sits in (95% Wilson), so 0 of 2 shows how bad it could be, not a
|
|
3241
|
+
// clean record. Worst proven at the top: by the low end of that range. Few answers: faint.
|
|
3087
3242
|
put('chart-wrong', function (w) {
|
|
3088
|
-
var T = C.wrongTotals;
|
|
3089
|
-
|
|
3243
|
+
var T = C.wrongTotals, R = {};
|
|
3244
|
+
Object.keys(T).forEach(function (m) {
|
|
3245
|
+
var b = wilson(T[m].wrong, T[m].answers);
|
|
3246
|
+
R[m] = {model: m, lo: b[0], hi: b[1], few: T[m].answers < FEW};
|
|
3247
|
+
});
|
|
3248
|
+
var models = Object.keys(T).sort(function (a, b) {
|
|
3249
|
+
return R[b].lo - R[a].lo || T[b].rate - T[a].rate || T[b].answers - T[a].answers;
|
|
3250
|
+
});
|
|
3251
|
+
var ranges = models.map(function (m) { return R[m]; });
|
|
3252
|
+
var top = Math.min(1, Math.max.apply(null, ranges.map(function (r) { return r.hi; })) + 0.02);
|
|
3090
3253
|
return Plot.plot({
|
|
3091
|
-
width: w, height: 20 * models.length + 70, style: style, marginLeft: 250,
|
|
3092
|
-
x: {label: 'Confidently wrong, share of answers that could have been →', tickFormat: '.0%', grid: true},
|
|
3254
|
+
width: w, height: 20 * models.length + 70, style: style, marginLeft: w < 560 ? 150 : 250,
|
|
3255
|
+
x: {domain: [0, top], label: 'Confidently wrong, share of answers that could have been →', tickFormat: '.0%', grid: true},
|
|
3093
3256
|
// The count beside each name: 1 of 2 is not the same evidence as 17 of 26.
|
|
3094
|
-
y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + T[m].answers + ')'; }},
|
|
3257
|
+
y: {domain: models, label: null, tickFormat: function (m) { return fit(m, w < 560 ? 16 : 28) + ' (' + T[m].answers + ')'; }},
|
|
3095
3258
|
color: {legend: true, domain: C.kinds, range: [err, signal, sky, muted]},
|
|
3096
3259
|
marks: [
|
|
3097
|
-
Plot.barX(C.wrong, {x: 'rate', y: 'model', fill: 'kind',
|
|
3260
|
+
Plot.barX(C.wrong, {x: 'rate', y: 'model', fill: 'kind', insetTop: 3, insetBottom: 3,
|
|
3261
|
+
fillOpacity: function (d) { return R[d.model].few ? 0.4 : 1; },
|
|
3262
|
+
title: function (d) { return d.model + ': ' + d.kind + ' ' + d.n; }}),
|
|
3263
|
+
Plot.ruleY(ranges, {y: 'model', x1: 'lo', x2: 'hi', stroke: ink, strokeOpacity: 0.45, strokeWidth: 1.5,
|
|
3264
|
+
title: function (r) {
|
|
3265
|
+
return r.model + ': ' + T[r.model].wrong + ' of ' + T[r.model].answers + ' confidently wrong\nlikely between '
|
|
3266
|
+
+ Math.round(r.lo * 100) + '% and ' + Math.round(r.hi * 100) + '%' + (r.few ? '\nfew answers: little to go on' : '');
|
|
3267
|
+
}}),
|
|
3098
3268
|
Plot.ruleX([0], {stroke: ink})
|
|
3099
3269
|
]
|
|
3100
3270
|
});
|