@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.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.
- package/.github/logo-dark.svg +49 -0
- package/.github/logo.svg +49 -0
- package/BENCHMARKS.md +160 -0
- package/GATEWAY.md +33 -11
- package/PRIVACY.md +20 -6
- package/README.md +46 -11
- package/SECURITY.md +20 -10
- package/USAGE.md +71 -9
- package/VERSION +1 -1
- package/gateway.py +93 -82
- package/metrics/bench_collections.py +27 -3
- package/metrics/catalog.py +52 -4
- package/metrics/client.py +51 -1
- package/metrics/connect.py +225 -0
- package/metrics/decide.py +690 -27
- package/metrics/endpoints.py +4 -6
- package/metrics/guard.py +6 -4
- package/metrics/ledger.py +3 -1
- package/metrics/locked.py +102 -1
- package/metrics/offload.py +36 -19
- package/metrics/route.py +9 -1
- package/metrics/scheduler.py +11 -1
- package/metrics/test_decide.py +275 -7
- package/metrics/wrong.py +21 -9
- package/package.json +3 -1
- package/scripts/auto_retire.py +96 -21
- package/scripts/onboard.py +44 -3
- package/scripts/test_connect.py +152 -3
- package/scripts/test_first_run.py +4 -2
- package/scripts/test_formats.py +39 -0
- package/scripts/test_serve_edit.py +308 -4
- package/serve.py +1672 -141
- package/suite/README.md +5 -2
- package/suite/run.py +226 -6
- package/suite/test_pick.py +15 -0
- package/suite/test_score.py +82 -1
- package/ui/bench.css +44 -0
- package/ui/bench.js +910 -203
package/metrics/decide.py
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
--choice review,design,enumeration,code
|
|
5
5
|
python metrics/decide.py span-01-lite-free "Please refund the duplicate charge." \\
|
|
6
6
|
--noul "Is this about billing?"
|
|
7
|
+
python metrics/decide.py bench [--per 1] [--gap 30] [--sets errors,shape] [--models a,b] [--dry-run]
|
|
8
|
+
python metrics/decide.py results
|
|
7
9
|
|
|
8
10
|
DECISION MODELS ARE NOT CHAT MODELS
|
|
9
11
|
-----------------------------------
|
|
@@ -39,14 +41,87 @@ with one question of each: Mercury Decide takes noul, choice and score;
|
|
|
39
41
|
Respan takes noul only, with plain-string instructions and criteria. `ask`
|
|
40
42
|
refuses a type the entry does not list, so a caller learns it from the bench
|
|
41
43
|
rather than from a 400. Both providers take the same body (`model`, `state`,
|
|
42
|
-
`questions`) and return the same `answers`, so one client covers them
|
|
44
|
+
`questions`) and return the same `answers`, so one client covers them:
|
|
45
|
+
noul `{"noul": p_yes}`; choice `{"choice", "probabilities": {option: p},
|
|
46
|
+
"confidence"}`; score `{"score", "legend", "probabilities", "confidence"}`.
|
|
47
|
+
|
|
48
|
+
EVERY CALL IS WRITTEN DOWN
|
|
49
|
+
--------------------------
|
|
50
|
+
`call` is the only way out, so every question asked (the gateway's picks,
|
|
51
|
+
onboarding's probes and role suggestion, the benchmark) is one line in
|
|
52
|
+
metrics/decisions.jsonl: when, the model, what it was for (`use`), the
|
|
53
|
+
question types, the answers with their probabilities, seconds, and the error
|
|
54
|
+
(redacted, clipped). The state is not kept, only its length. A benchmark
|
|
55
|
+
line adds its set, item and label. Not in the usage ledger: these are not
|
|
56
|
+
the built-in client's chat calls, and no pacing rule counts them.
|
|
57
|
+
|
|
58
|
+
THE DECISION BENCHMARK (`bench`)
|
|
59
|
+
--------------------------------
|
|
60
|
+
Labelled sets built from the bench's own data, no hand labelling:
|
|
61
|
+
|
|
62
|
+
errors choice: failed calls' error text as shapes (redacted; numbers,
|
|
63
|
+
ids, emails and URLs as placeholders, 4xx/5xx kept), labelled by
|
|
64
|
+
suite/run.uptime_class's rules alone (never a cached decision),
|
|
65
|
+
the classes in ERROR_CLASSES
|
|
66
|
+
collection choice: gateway requests (runs/gateway/calls.jsonl), labelled
|
|
67
|
+
with the collection the caller named; options are the
|
|
68
|
+
collections not ad hoc, with their descriptions and tags
|
|
69
|
+
useful noul: text seat answers the owner judged (metrics/offloads.jsonl),
|
|
70
|
+
with their request; USEFUL_EACH of each verdict, newest first
|
|
71
|
+
|
|
72
|
+
No shape set (dropped 2026-11-08): a collection's own shape says which
|
|
73
|
+
collection a caller used, not what the prompt is, so it measured the
|
|
74
|
+
collections' settings rather than the models. A request the gateway's own
|
|
75
|
+
pick sent to a collection (`picked`) labels nothing: that label would be a
|
|
76
|
+
decision model's. Each run asks every
|
|
77
|
+
onboarded model (not ad hoc) `--per` questions per set it can answer, the
|
|
78
|
+
ones it was asked longest ago first, ROUND-ROBIN across models `--gap`
|
|
79
|
+
seconds apart (suite/pace.py's lesson: one model is asked once a lap, never
|
|
80
|
+
in a burst), and stops a model at its first failure. The `:free` models
|
|
81
|
+
share OpenRouter's free daily budget, so the scheduled run is one question
|
|
82
|
+
per model per set. Per model and set, from each item's latest answer in the
|
|
83
|
+
log: accuracy (macro-F1 for choice), Brier score and log loss from the
|
|
84
|
+
probabilities, calibration (five bins by the probability given to the
|
|
85
|
+
answer, and their weighted gap, ECE), coverage and accuracy at CONFIDENT
|
|
86
|
+
(read from the answer's `confidence`, as the gateway reads it), median
|
|
87
|
+
seconds, and failures by uptime_class. Written to metrics/decide-bench.json.
|
|
88
|
+
|
|
89
|
+
WHO IS ASKED IN USE (`choose`, `best`)
|
|
90
|
+
--------------------------------------
|
|
91
|
+
Each use is ranked on one set (RANKED_BY): its own, or the nearest one of
|
|
92
|
+
its question type (the gateway's shape pick and a new model's role on the
|
|
93
|
+
collection set, both one described option of several for a text; the
|
|
94
|
+
provider-notice check on useful, the only yes/no set). Only models with
|
|
95
|
+
MIN_ITEMS or more items asked on that set are ranked, by correct answers
|
|
96
|
+
Laplace-smoothed ((correct + 1) / (asked + 2), failures counted as wrong), so
|
|
97
|
+
one lucky answer does not outrank a long record; ties go to config order.
|
|
98
|
+
With none ranked, the first onboarded model that takes the type, as before
|
|
99
|
+
the benchmark. A use made per request (PER_REQUEST: the gateway's picks, the
|
|
100
|
+
notice check) never goes to a `:free` model, whose calls come out of
|
|
101
|
+
OpenRouter's free daily budget that the suite and the probe share, while
|
|
102
|
+
another model takes the question; the daily benchmark may, paced.
|
|
103
|
+
|
|
104
|
+
WHAT THE ANSWERS ARE KEPT FOR (metrics/decided.json)
|
|
105
|
+
----------------------------------------------------
|
|
106
|
+
`roles`: a chat model's suggested role from onboarding (suggest_role),
|
|
107
|
+
shown, never set. `errors`: an unrecognised error shape's class
|
|
108
|
+
(classify_errors, a few new shapes a scheduled run), which uptime_class reads
|
|
109
|
+
through cached_error_class; never asked at render time. `notices`: whether a
|
|
110
|
+
short reply the phrase list missed is a provider's notice (notice), asked
|
|
111
|
+
from the gateway's seats only, read everywhere else.
|
|
43
112
|
"""
|
|
44
113
|
|
|
45
114
|
from __future__ import annotations
|
|
46
115
|
|
|
47
116
|
import argparse
|
|
117
|
+
import collections
|
|
118
|
+
import datetime as dt
|
|
119
|
+
import hashlib
|
|
48
120
|
import json
|
|
121
|
+
import math
|
|
49
122
|
import pathlib
|
|
123
|
+
import re
|
|
124
|
+
import statistics
|
|
50
125
|
import sys
|
|
51
126
|
import time
|
|
52
127
|
import urllib.error
|
|
@@ -55,7 +130,11 @@ import urllib.request
|
|
|
55
130
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
56
131
|
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
57
132
|
FILE = DATA / "config" / "decision-models.json"
|
|
133
|
+
LOG = DATA / "metrics" / "decisions.jsonl"
|
|
134
|
+
RESULTS = DATA / "metrics" / "decide-bench.json"
|
|
135
|
+
DECIDED = DATA / "metrics" / "decided.json"
|
|
58
136
|
sys.path.insert(0, str(ROOT / "metrics"))
|
|
137
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
59
138
|
import endpoints # noqa: E402
|
|
60
139
|
|
|
61
140
|
# One question of each type, plain strings throughout so Respan's rule holds.
|
|
@@ -76,39 +155,160 @@ PROBE_STATE = "Please refund the duplicate charge on my account today."
|
|
|
76
155
|
# with each, and the one that answered is recorded on the model as `path`.
|
|
77
156
|
PATHS = ("decisions", "systemone")
|
|
78
157
|
|
|
158
|
+
# What the gateway's shape picker chooses between. Each option carries a
|
|
159
|
+
# description because bare names mislead it: Mercury Decide called "list every
|
|
160
|
+
# place serve.py writes a file" `code` at 0.93 when given names alone.
|
|
161
|
+
SHAPES = {
|
|
162
|
+
"general": "A general question or task that fits none of the other kinds.",
|
|
163
|
+
"design": "Architecture, process or approach decisions: weighing options, an ADR, a plan.",
|
|
164
|
+
"prose": "Writing or editing documentation or other text.",
|
|
165
|
+
"review": "Judging whether given code is correct: finding bugs, or where a guarantee breaks.",
|
|
166
|
+
"code": "Writing new code or changing existing code.",
|
|
167
|
+
"enumeration": "Listing every place something happens in given code or files.",
|
|
168
|
+
"quick": "A short, bounded task: classify, extract or summarise a small input.",
|
|
169
|
+
}
|
|
170
|
+
# The uptime classes the error set asks about (the owner's list, 2026-11-08):
|
|
171
|
+
# rate, notsent, client and relay are left out, as is unrecognised.
|
|
172
|
+
ERROR_CLASSES = {
|
|
173
|
+
"quota": "Our own long-window allowance is used up: a daily, monthly or hourly quota, credits, a usage limit.",
|
|
174
|
+
"busy": "The provider is overloaded or failing: high demand, a shared pool at its limit, a server error"
|
|
175
|
+
" (5xx), or a 429 that names no allowance of ours.",
|
|
176
|
+
"timeout": "No answer in time: timed out, deadline exceeded, a 408, 504 or 524.",
|
|
177
|
+
"unreachable": "The connection failed or broke: refused, reset, DNS, SSL, a broken or empty stream,"
|
|
178
|
+
" an HTML error page.",
|
|
179
|
+
"setup": "Our setup or our request: the sign-in was refused, no such model, no access or paywalled,"
|
|
180
|
+
" or a request the provider refused as malformed or too large.",
|
|
181
|
+
"empty": "The call came back with no text and no error.",
|
|
182
|
+
}
|
|
183
|
+
# The question each use asks, the same in the benchmark and in use, so the
|
|
184
|
+
# benchmark measures what is used.
|
|
185
|
+
INSTRUCTIONS = {
|
|
186
|
+
"errors": "What kind of failure does this error from a call to a model provider show?",
|
|
187
|
+
"collection": "Which collection of models should answer this request?",
|
|
188
|
+
"shape": "What kind of task does this prompt ask for?",
|
|
189
|
+
"useful": "Is this answer useful for the request?",
|
|
190
|
+
"role": "What is this chat model for, judging by its catalog facts and description?",
|
|
191
|
+
"notice": "Is this reply a notice from the provider about the account instead of an answer?",
|
|
192
|
+
}
|
|
193
|
+
NOULS = {"useful": {"true": "The answer addresses the request, what it claims holds, and it is worth acting on.",
|
|
194
|
+
"false": "The answer is wrong, beside the point, or not worth acting on."},
|
|
195
|
+
"notice": {"true": "It is the provider speaking about the account: credits, a quota, a plan, a"
|
|
196
|
+
" balance or a limit, not an answer to anything asked.",
|
|
197
|
+
"false": "It is an answer, however short, or anything else."}}
|
|
198
|
+
KINDS = {"errors": "choice", "collection": "choice", "shape": "choice", "useful": "noul", "role": "choice",
|
|
199
|
+
"notice": "noul"}
|
|
200
|
+
SETS = ("errors", "collection", "useful")
|
|
201
|
+
# The set each use is ranked on (module docstring); a set ranks itself.
|
|
202
|
+
RANKED_BY = {"shape": "collection", "role": "collection", "notice": "useful"}
|
|
203
|
+
USES = ("shape", "collection", "notice", "errors", "role") # as the Benchmarks card lists them
|
|
204
|
+
PER_REQUEST = ("shape", "collection", "notice") # asked while a caller waits
|
|
205
|
+
MIN_ITEMS = 8 # items asked on a set before a model is ranked on it
|
|
206
|
+
CONFIDENT = 0.6 # the gateway acts on a choice only at this confidence or more
|
|
207
|
+
MAX_STATE = 20_000 # characters of request sent, as pick_shape always cut it
|
|
208
|
+
PER_LABEL = 12 # items per label in a choice set, so one busy label cannot fill it
|
|
209
|
+
USEFUL_EACH = 20 # judged answers of each verdict in the useful set
|
|
210
|
+
EMPTY_STATE = "(the call came back with no text and no error)"
|
|
211
|
+
|
|
79
212
|
|
|
80
213
|
def load() -> dict[str, dict]:
|
|
214
|
+
import locked
|
|
81
215
|
try:
|
|
82
|
-
return json.loads(
|
|
216
|
+
return json.loads(locked.read_text(FILE)) # inside a render, from its snapshot
|
|
83
217
|
except (OSError, ValueError):
|
|
84
218
|
return {}
|
|
85
219
|
|
|
86
220
|
|
|
87
221
|
def call(endpoint: str, served: str, state, questions: dict, timeout: int = 60,
|
|
88
|
-
path: str = "decisions"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
222
|
+
path: str = "decisions", alias: str = "", use: str = "", meta: dict | None = None,
|
|
223
|
+
retry: bool = True) -> dict:
|
|
224
|
+
"""POST to an endpoint's decisions URL. Returns the parsed body; raises on
|
|
225
|
+
HTTP errors. Every call is logged (metrics/decisions.jsonl), answered or
|
|
226
|
+
not; `alias` and `use` say who was asked and what for, `meta` adds the
|
|
227
|
+
benchmark's set, item and label. `retry`: a 429 waits and tries again."""
|
|
228
|
+
t, started, reply, error = time.time(), time.monotonic(), None, ""
|
|
229
|
+
try:
|
|
230
|
+
# Inside the try, so an endpoint gone from the config is logged too.
|
|
231
|
+
entry = endpoints.load()[endpoint]
|
|
232
|
+
url = (entry.get("decisions") or "").replace("{path}", path)
|
|
233
|
+
if not url:
|
|
234
|
+
raise ValueError(f"{endpoint} has no decisions URL in config/endpoints.json")
|
|
235
|
+
headers = {"Content-Type": "application/json", **(entry.get("headers") or {})}
|
|
236
|
+
if (secret := endpoints.key(endpoint)):
|
|
237
|
+
headers["Authorization"] = f"Bearer {secret}"
|
|
238
|
+
body = json.dumps({"model": served, "state": state, "questions": questions}).encode()
|
|
239
|
+
# Inception answers 429 to calls in quick succession (seen 2026-10-16:
|
|
240
|
+
# the second of three onboarding probes), so a 429 waits and retries.
|
|
241
|
+
for wait in ((5, 15, 0) if retry else (0,)):
|
|
242
|
+
request = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
243
|
+
try:
|
|
244
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
245
|
+
reply = json.load(response)
|
|
246
|
+
return reply
|
|
247
|
+
except urllib.error.HTTPError as exc:
|
|
248
|
+
if exc.code != 429 or not wait:
|
|
249
|
+
raise
|
|
250
|
+
time.sleep(wait)
|
|
251
|
+
except Exception as exc: # noqa: BLE001 -- logged, then raised as it was
|
|
252
|
+
error = error_text(exc)
|
|
102
253
|
try:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
254
|
+
exc.decide_text = error # an HTTP error's body can be read only once
|
|
255
|
+
except AttributeError:
|
|
256
|
+
pass
|
|
257
|
+
raise
|
|
258
|
+
finally:
|
|
259
|
+
_log({"t": round(t, 3), "at": dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds"),
|
|
260
|
+
"alias": alias or served, "served": served, "via": endpoint, "use": use,
|
|
261
|
+
"types": ({k: q.get("type") if isinstance(q, dict) else None for k, q in questions.items()}
|
|
262
|
+
if isinstance(questions, dict) else None),
|
|
263
|
+
"chars": len(state) if isinstance(state, str) else None,
|
|
264
|
+
"answers": (reply or {}).get("answers") if isinstance(reply, dict) else None,
|
|
265
|
+
"seconds": round(time.monotonic() - started, 2), "error": _clean(error), **(meta or {})})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _clean(error: str) -> str:
|
|
269
|
+
"""An error as the log keeps it: redacted (images, URL queries, base64), clipped."""
|
|
270
|
+
if not error:
|
|
271
|
+
return ""
|
|
272
|
+
import client
|
|
273
|
+
return client.clip(client.redact(error))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _log(row: dict) -> None:
|
|
277
|
+
"""One call's line. Never fails the call it records."""
|
|
278
|
+
import locked
|
|
279
|
+
try:
|
|
280
|
+
with locked.lock(LOG):
|
|
281
|
+
with LOG.open("a", encoding="utf-8", newline="\n") as fh:
|
|
282
|
+
fh.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
283
|
+
except Exception: # noqa: BLE001 -- a full disk must not lose the answer
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def log_rows() -> list[dict]:
|
|
288
|
+
"""Every logged call, oldest first."""
|
|
289
|
+
return _jsonl(LOG)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _jsonl(path: pathlib.Path) -> list[dict]:
|
|
293
|
+
"""A log's objects, oldest first; a line still being written is skipped."""
|
|
294
|
+
out = []
|
|
295
|
+
try:
|
|
296
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
297
|
+
for line in fh:
|
|
298
|
+
try:
|
|
299
|
+
row = json.loads(line)
|
|
300
|
+
except ValueError:
|
|
301
|
+
continue
|
|
302
|
+
if isinstance(row, dict):
|
|
303
|
+
out.append(row)
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
306
|
+
return out
|
|
109
307
|
|
|
110
308
|
|
|
111
309
|
def error_text(exc: Exception) -> str:
|
|
310
|
+
if (seen := getattr(exc, "decide_text", None)) is not None:
|
|
311
|
+
return seen
|
|
112
312
|
if isinstance(exc, urllib.error.HTTPError):
|
|
113
313
|
try:
|
|
114
314
|
detail = json.loads(exc.read() or b"{}")
|
|
@@ -132,7 +332,7 @@ def probe(endpoint: str, served: str) -> tuple[str | None, str]:
|
|
|
132
332
|
why = []
|
|
133
333
|
for path in (PATHS if "{path}" in url else PATHS[:1]):
|
|
134
334
|
try:
|
|
135
|
-
answer = call(endpoint, served, PROBE_STATE, {"probe": PROBES["noul"]}, path=path)
|
|
335
|
+
answer = call(endpoint, served, PROBE_STATE, {"probe": PROBES["noul"]}, path=path, use="onboard probe")
|
|
136
336
|
except Exception as exc: # noqa: BLE001
|
|
137
337
|
why.append(f"{endpoint} /{path}: {error_text(exc)}")
|
|
138
338
|
continue
|
|
@@ -177,24 +377,487 @@ def question_types(endpoint: str, served: str, path: str = "decisions") -> list[
|
|
|
177
377
|
if kind in ok:
|
|
178
378
|
continue
|
|
179
379
|
try:
|
|
180
|
-
if kind in call(endpoint, served, PROBE_STATE, {kind: question}, path=path
|
|
380
|
+
if kind in call(endpoint, served, PROBE_STATE, {kind: question}, path=path,
|
|
381
|
+
use="onboard question types").get("answers", {}):
|
|
181
382
|
ok.append(kind)
|
|
182
383
|
except Exception: # noqa: BLE001
|
|
183
384
|
pass
|
|
184
385
|
return ok
|
|
185
386
|
|
|
186
387
|
|
|
187
|
-
def ask(alias: str, state, questions: dict
|
|
388
|
+
def ask(alias: str, state, questions: dict, use: str = "", meta: dict | None = None, retry: bool = True,
|
|
389
|
+
timeout: int = 60) -> dict:
|
|
188
390
|
entry = load()[alias]
|
|
189
391
|
wrong = sorted({q["type"] for q in questions.values()} - set(entry["question_types"]))
|
|
190
392
|
if wrong:
|
|
191
393
|
raise ValueError(f"{alias} does not take {', '.join(wrong)} questions;"
|
|
192
394
|
f" it takes {', '.join(entry['question_types'])}")
|
|
193
|
-
return call(entry["via"], entry["served"], state, questions,
|
|
395
|
+
return call(entry["via"], entry["served"], state, questions, timeout=timeout,
|
|
396
|
+
path=entry.get("path", "decisions"), alias=alias, use=use, meta=meta, retry=retry)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---- in use: who is asked, and the shared question --------------------------
|
|
400
|
+
|
|
401
|
+
def question(name: str, options: dict | None = None) -> dict:
|
|
402
|
+
"""The question a set or use asks. Choice options are {name: description}."""
|
|
403
|
+
if KINDS[name] == "noul":
|
|
404
|
+
return {"type": "noul", "instructions": INSTRUCTIONS[name], "criteria": NOULS[name]}
|
|
405
|
+
return {"type": "choice", "instructions": INSTRUCTIONS[name],
|
|
406
|
+
"criteria": options if options is not None else {"errors": ERROR_CLASSES, "shape": SHAPES}[name]}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def results() -> dict:
|
|
410
|
+
import locked
|
|
411
|
+
return locked.read_json(RESULTS)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def shares_free(alias: str, models: dict | None = None) -> bool:
|
|
415
|
+
"""A `:free` model: its calls come out of OpenRouter's free daily budget,
|
|
416
|
+
shared with the suite's free rotation and the free models' probes (the
|
|
417
|
+
same mark suite/run.free_models reads)."""
|
|
418
|
+
return ":free" in ((models if models is not None else load()).get(alias) or {}).get("served", "")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def choose(name: str, measured: dict | None = None) -> tuple[str | None, str]:
|
|
422
|
+
"""(the model to ask for `name`, why that one): a set, or a use in USES.
|
|
423
|
+
Ranked on RANKED_BY's set among models with MIN_ITEMS or more items there,
|
|
424
|
+
else the first onboarded that takes the question's type; never ad hoc, and
|
|
425
|
+
per request never a `:free` model while another takes it (module
|
|
426
|
+
docstring). `measured`: the results' models, read when not given."""
|
|
427
|
+
import model_roles
|
|
428
|
+
kind, held, models = KINDS[name], set(model_roles.load()["ad_hoc"]), load()
|
|
429
|
+
ready = [a for a, d in models.items() if kind in (d.get("question_types") or []) and a not in held]
|
|
430
|
+
spared = ""
|
|
431
|
+
if name in PER_REQUEST and (paid := [a for a in ready if not shares_free(a, models)]):
|
|
432
|
+
spared = "; :free models spared, as it is asked per request" if len(paid) < len(ready) else ""
|
|
433
|
+
ready = paid
|
|
434
|
+
if not ready:
|
|
435
|
+
return None, f"no onboarded decision model takes {kind} questions"
|
|
436
|
+
on = RANKED_BY.get(name, name)
|
|
437
|
+
measured = (results().get("models") or {}) if measured is None else measured
|
|
438
|
+
rows = {a: r for a in ready if isinstance(r := (measured.get(a) or {}).get(on), dict)
|
|
439
|
+
and r.get("asked", 0) >= MIN_ITEMS}
|
|
440
|
+
if not rows:
|
|
441
|
+
return ready[0], (f"the default: the first onboarded that takes {kind} questions; none has"
|
|
442
|
+
f" {MIN_ITEMS} items on the {on} set yet{spared}")
|
|
443
|
+
score = {a: (r.get("correct", 0) + 1) / (r["asked"] + 2) for a, r in rows.items()}
|
|
444
|
+
top = max(rows, key=lambda a: score[a]) # the first of a tie: config order
|
|
445
|
+
r = rows[top]
|
|
446
|
+
return top, (f"best of {len(rows)} ranked on the {on} set: {r.get('correct', 0)} right of {r['asked']}"
|
|
447
|
+
f" asked{spared}")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def best(name: str, measured: dict | None = None) -> str | None:
|
|
451
|
+
"""The model `choose` names for `name`."""
|
|
452
|
+
return choose(name, measured)[0]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def pick(name: str, state: str, options: dict | None, use: str, timeout: int = 60) -> tuple[dict, str, str]:
|
|
456
|
+
"""(the answer, the model asked, why there is none): `name`'s question,
|
|
457
|
+
with these options, to its model (choose). The answer is {} when there is
|
|
458
|
+
no model or the call failed, and `why` says which."""
|
|
459
|
+
try:
|
|
460
|
+
alias = best(name)
|
|
461
|
+
except Exception as exc: # noqa: BLE001 -- a hand-broken config: no decision, never a 500
|
|
462
|
+
return {}, "", f"no decision model could be chosen ({type(exc).__name__}: {exc})"
|
|
463
|
+
if not alias:
|
|
464
|
+
return {}, "", "no decision model onboarded"
|
|
465
|
+
try:
|
|
466
|
+
return (ask(alias, state[:MAX_STATE], {name: question(name, options)}, use=use, timeout=timeout)
|
|
467
|
+
["answers"][name], alias, "")
|
|
468
|
+
except Exception as exc: # noqa: BLE001
|
|
469
|
+
return {}, alias, f"{alias} failed ({error_text(exc)[:80]})"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def collection_options(cols: dict) -> dict:
|
|
473
|
+
"""Each collection as a choice option: its description and tags."""
|
|
474
|
+
return {n: (c.get("description") or c.get("note") or "No description.")
|
|
475
|
+
+ (f" Tags: {', '.join(c['tags'])}." if c.get("tags") else "") for n, c in cols.items()}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def suggest_role(alias: str, facts: str) -> dict:
|
|
479
|
+
"""A decision model's suggestion for a chat model's role (metrics/model_roles.py),
|
|
480
|
+
from its catalog facts and description, kept in metrics/decided.json under
|
|
481
|
+
`roles` with its confidence. A suggestion only: the role is never set from it."""
|
|
482
|
+
import locked
|
|
483
|
+
import model_roles
|
|
484
|
+
roles = {r: d for r, d in model_roles.load()["roles"].items() if r != "decision"}
|
|
485
|
+
answer, by, why = pick("role", facts, roles, f"role {alias}")
|
|
486
|
+
if why or answer.get("choice") not in roles:
|
|
487
|
+
return {"error": why or f"{by} answered {answer.get('choice')!r}, not a role"}
|
|
488
|
+
rec = {"role": answer["choice"], "confidence": round(float(answer.get("confidence") or 0), 3),
|
|
489
|
+
"probabilities": answer.get("probabilities") or {}, "by": by,
|
|
490
|
+
"at": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
|
|
491
|
+
locked.update(DECIDED, lambda d: d.setdefault("roles", {}).__setitem__(alias, rec), ensure_ascii=False)
|
|
492
|
+
return rec
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ---- error shapes -------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(\.[\w-]+)+")
|
|
498
|
+
ID = re.compile(r"\b(?=[A-Za-z_-]*\d)(?=[\d_-]*[A-Za-z])[A-Za-z0-9_-]{8,}\b|\b[A-Za-z0-9_-]{24,}\b")
|
|
499
|
+
NUMBER = re.compile(r"\d+(\.\d+)?")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def shape(text: str) -> str:
|
|
503
|
+
"""An error as a shape, the same failure the same text whichever call it
|
|
504
|
+
came from, with nothing private in it: client.redact first, then URLs,
|
|
505
|
+
emails, ids and keys (runs of 24 or more, or of 8 or more mixing letters
|
|
506
|
+
and digits) as placeholders, numbers as N except a 4xx or 5xx status, whitespace
|
|
507
|
+
collapsed, start and end kept. The benchmark's error state and the
|
|
508
|
+
cache's key."""
|
|
509
|
+
import client
|
|
510
|
+
s = re.sub(r"https?://\S+", "<url>", client.redact(text))
|
|
511
|
+
s = ID.sub("<id>", EMAIL.sub("<email>", s))
|
|
512
|
+
s = NUMBER.sub(lambda m: m.group() if re.fullmatch(r"[45]\d\d", m.group()) else "N", s)
|
|
513
|
+
return client.clip(" ".join(s.split()), 250, 250)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _gateway_calls() -> list[dict]:
|
|
517
|
+
return _jsonl(DATA / "runs" / "gateway" / "calls.jsonl")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def error_texts(calls: list[dict] | None = None) -> list[tuple[str, str]]:
|
|
521
|
+
"""(status, text) of every failed call the bench kept text for: gateway
|
|
522
|
+
seats, the probe's detail (perf/history.csv) and the usage ledger."""
|
|
523
|
+
import csv
|
|
524
|
+
import ledger
|
|
525
|
+
out = [(s["status"], s.get("error") or "") for r in (calls if calls is not None else _gateway_calls())
|
|
526
|
+
for s in r.get("seats") or [] if s.get("status") in ("error", "empty")]
|
|
527
|
+
try:
|
|
528
|
+
with (DATA / "perf" / "history.csv").open(encoding="utf-8", newline="") as fh:
|
|
529
|
+
out += [("error", r["detail"]) for r in csv.DictReader(fh) if r.get("detail")]
|
|
530
|
+
except OSError:
|
|
531
|
+
pass
|
|
532
|
+
return out + [(r.get("status") or "error", r["error"]) for r in ledger.rows() if r.get("error")]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def classify_errors(limit: int = 3, gap: float = 30, sleep=time.sleep) -> int:
|
|
536
|
+
"""Ask the errors set's best model about up to `limit` error shapes that
|
|
537
|
+
uptime_class does not recognise and nobody asked about yet, one call each,
|
|
538
|
+
`gap` seconds apart, stopping at the first failure; each answer goes to
|
|
539
|
+
metrics/decided.json `errors`. Returns the calls made."""
|
|
540
|
+
import locked
|
|
541
|
+
from run import uptime_class
|
|
542
|
+
have = locked.read_json(DECIDED).get("errors") or {}
|
|
543
|
+
new = list(dict.fromkeys(shape(t) for s, t in error_texts()
|
|
544
|
+
if t.strip() and uptime_class(t, s, decided=False)[0] == "unrecognised"))
|
|
545
|
+
new = [k for k in new if k not in have][:limit]
|
|
546
|
+
alias = best("errors")
|
|
547
|
+
made = 0
|
|
548
|
+
for key in new if alias else ():
|
|
549
|
+
if made:
|
|
550
|
+
sleep(gap)
|
|
551
|
+
made += 1
|
|
552
|
+
try:
|
|
553
|
+
a = ask(alias, key, {"errors": question("errors")}, use="errors cache", retry=False)["answers"]["errors"]
|
|
554
|
+
except Exception as exc: # noqa: BLE001
|
|
555
|
+
print(f" {alias} failed on an error shape: {error_text(exc)[:120]}; stopping")
|
|
556
|
+
break
|
|
557
|
+
rec = {"class": a.get("choice"), "confidence": round(float(a.get("confidence") or 0), 3), "by": alias,
|
|
558
|
+
"at": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
|
|
559
|
+
locked.update(DECIDED, lambda d: d.setdefault("errors", {}).__setitem__(key, rec), ensure_ascii=False)
|
|
560
|
+
print(f" {alias}: {rec['class']} at {rec['confidence']:.2f} {key[:90]}")
|
|
561
|
+
return made
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def cached_error_class(text: str) -> tuple[str, str] | None:
|
|
565
|
+
"""(class, "decided by <model> at <confidence>") a decision model gave
|
|
566
|
+
this error's shape (classify_errors) at CONFIDENT or more, else None.
|
|
567
|
+
Never `empty`: an error is not an empty answer, and empty counts as up.
|
|
568
|
+
Reads only: never a call (uptime_class's fallback, at render time)."""
|
|
569
|
+
import locked
|
|
570
|
+
hit = (locked.read_json(DECIDED).get("errors") or {}).get(shape(text))
|
|
571
|
+
if (isinstance(hit, dict) and hit.get("class") in ERROR_CLASSES and hit["class"] != "empty"
|
|
572
|
+
and isinstance(hit.get("confidence"), (int, float)) and hit["confidence"] >= CONFIDENT):
|
|
573
|
+
return hit["class"], f"decided by {hit.get('by')} at {hit['confidence']:.2f}"
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def notice(text: str, ask_now: bool = False, timeout: int = 15) -> str | None:
|
|
578
|
+
""""decided by <model> at <p>" when a decision model said a short reply
|
|
579
|
+
is a provider's notice about the account (at CONFIDENT or more), else
|
|
580
|
+
None. Its answer is kept by the reply's shape (decided.json `notices`)
|
|
581
|
+
and read from there; it is asked only when `ask_now` and the shape was
|
|
582
|
+
never asked (suite/run.provider_notice says when: a gateway seat's reply
|
|
583
|
+
the phrase list missed that looks like one). A failed call is not kept,
|
|
584
|
+
so the next such reply asks again."""
|
|
585
|
+
import locked
|
|
586
|
+
key = shape(text)
|
|
587
|
+
hit = (locked.read_json(DECIDED).get("notices") or {}).get(key)
|
|
588
|
+
if hit is None and ask_now:
|
|
589
|
+
answer, by, why = pick("notice", text, None, "notice", timeout=timeout)
|
|
590
|
+
if why or not isinstance(answer.get("noul"), (int, float)):
|
|
591
|
+
return None
|
|
592
|
+
hit = {"p": round(float(answer["noul"]), 3), "by": by,
|
|
593
|
+
"at": dt.datetime.now().astimezone().isoformat(timespec="seconds")}
|
|
594
|
+
locked.update(DECIDED, lambda d: d.setdefault("notices", {}).__setitem__(key, hit), ensure_ascii=False)
|
|
595
|
+
if isinstance(hit, dict) and isinstance(hit.get("p"), (int, float)) and hit["p"] >= CONFIDENT:
|
|
596
|
+
return f"decided by {hit.get('by')} at {hit['p']:.2f}"
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---- the benchmark's sets -------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
def _id(state: str) -> str:
|
|
603
|
+
return hashlib.sha1(state.encode("utf-8", "replace")).hexdigest()[:12]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _capped(by: dict[str, list[str]], cap: int) -> list[dict]:
|
|
607
|
+
"""Items, at most `cap` per label, in each label's own order."""
|
|
608
|
+
return [{"id": _id(s), "state": s, "label": label} for label, states in by.items() for s in states[:cap]]
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def request_text(record: dict) -> str:
|
|
612
|
+
"""A gateway request as the gateway puts it to a decision model (system, then prompt)."""
|
|
613
|
+
return f"{record.get('system') or ''}\n\n{record.get('prompt') or ''}".strip()[:MAX_STATE]
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def build_sets(only=None, calls: list[dict] | None = None) -> dict[str, dict]:
|
|
617
|
+
"""{set: {"question", "items": [{id, state, label}]}} from the bench's data."""
|
|
618
|
+
import bench_collections
|
|
619
|
+
calls = _gateway_calls() if calls is None else calls
|
|
620
|
+
recent = [r for r in reversed(calls) if not r.get("picked")] # newest first; a pick labels nothing
|
|
621
|
+
out: dict[str, dict] = {}
|
|
622
|
+
want = set(only or SETS)
|
|
623
|
+
if "errors" in want:
|
|
624
|
+
from run import uptime_class
|
|
625
|
+
seen: dict[str, str] = {} # shape -> its class
|
|
626
|
+
count: collections.Counter = collections.Counter()
|
|
627
|
+
for status, text in error_texts(calls):
|
|
628
|
+
if not text.strip() and status != "empty":
|
|
629
|
+
continue
|
|
630
|
+
key = shape(text) if text.strip() else EMPTY_STATE
|
|
631
|
+
count[key] += 1
|
|
632
|
+
if key not in seen:
|
|
633
|
+
seen[key] = uptime_class(text, status, decided=False)[0] # the rules alone, never a decision
|
|
634
|
+
by = collections.defaultdict(list)
|
|
635
|
+
for key, _n in count.most_common(): # the commonest shapes of each class
|
|
636
|
+
if seen[key] in ERROR_CLASSES:
|
|
637
|
+
by[seen[key]].append(key)
|
|
638
|
+
out["errors"] = {"question": question("errors"), "items": _capped(by, PER_LABEL)}
|
|
639
|
+
if "collection" in want:
|
|
640
|
+
cols = {n: c for n, c in bench_collections.load().items() if not c.get("ad_hoc")}
|
|
641
|
+
by, seen = collections.defaultdict(list), set()
|
|
642
|
+
for r in recent:
|
|
643
|
+
name, state = (r.get("model") or "").removeprefix(bench_collections.PREFIX), request_text(r)
|
|
644
|
+
if name in cols and state and state not in seen:
|
|
645
|
+
seen.add(state)
|
|
646
|
+
by[name].append(state)
|
|
647
|
+
out["collection"] = {"question": question("collection", collection_options(cols)),
|
|
648
|
+
"items": _capped(by, PER_LABEL)}
|
|
649
|
+
if "useful" in want:
|
|
650
|
+
import offload
|
|
651
|
+
panels = {r.get("panel"): r for r in calls}
|
|
652
|
+
pool: dict[bool, list[str]] = {True: [], False: []}
|
|
653
|
+
for e in reversed(offload.entries()):
|
|
654
|
+
if not isinstance(e.get("useful"), bool) or not e.get("call") or str(e.get("note") or "").startswith("ui test"):
|
|
655
|
+
continue
|
|
656
|
+
r = panels.get(e["call"].rpartition(".")[0]) or {}
|
|
657
|
+
seat = next((s for s in r.get("seats") or [] if s.get("call") == e["call"]), None)
|
|
658
|
+
# An image request is left out: a decision model reads its placeholder, not the picture.
|
|
659
|
+
if not seat or seat.get("status") != "ok" or "[image:" in (r.get("prompt") or ""):
|
|
660
|
+
continue
|
|
661
|
+
pool[e["useful"]].append(f"THE REQUEST\n\n{request_text(r)[:8000]}\n\nTHE ANSWER\n\n{seat.get('answer') or ''}"
|
|
662
|
+
[:MAX_STATE])
|
|
663
|
+
out["useful"] = {"question": question("useful"),
|
|
664
|
+
"items": [{"id": _id(s), "state": s, "label": v} for v in (True, False)
|
|
665
|
+
for s in list(dict.fromkeys(pool[v]))[:USEFUL_EACH]]}
|
|
666
|
+
return out
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---- the benchmark's measures ---------------------------------------------------
|
|
670
|
+
|
|
671
|
+
def _scored(row: dict) -> dict | None:
|
|
672
|
+
"""One answer against its label, or None when there is no usable answer:
|
|
673
|
+
right, the probability given to the truth, the probability given to the
|
|
674
|
+
answer it chose (`said`, for calibration), the answer's `confidence` (what
|
|
675
|
+
the gateway's cutoff reads) and its Brier term (summed over the options,
|
|
676
|
+
so 0 to 2 for a choice)."""
|
|
677
|
+
a = (row.get("answers") or {}).get(row.get("set"))
|
|
678
|
+
if row.get("error") or not isinstance(a, dict):
|
|
679
|
+
return None
|
|
680
|
+
label = row.get("label")
|
|
681
|
+
try:
|
|
682
|
+
if isinstance(label, bool):
|
|
683
|
+
p = float(a["noul"])
|
|
684
|
+
said = max(p, 1 - p)
|
|
685
|
+
return {"pred": p >= 0.5, "right": (p >= 0.5) == label, "p_true": p if label else 1 - p,
|
|
686
|
+
"said": said, "conf": said, "brier": (p - label) ** 2}
|
|
687
|
+
probs = {str(k): float(v) for k, v in (a.get("probabilities") or {}).items()}
|
|
688
|
+
pred, conf = a.get("choice"), a.get("confidence")
|
|
689
|
+
conf = float(conf) if isinstance(conf, (int, float)) else None # absent or null alike
|
|
690
|
+
said = probs.get(pred, conf or 0.0)
|
|
691
|
+
return {"pred": pred, "right": pred == label, "p_true": probs.get(label, 0.0), "said": said,
|
|
692
|
+
"conf": said if conf is None else conf,
|
|
693
|
+
"brier": sum((probs.get(o, 0.0) - (o == label)) ** 2 for o in {*probs, label})}
|
|
694
|
+
except (KeyError, TypeError, ValueError):
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _private(via: str | None) -> bool:
|
|
699
|
+
"""The call went to a hub on our own network, so a refused connection is
|
|
700
|
+
that hub (uptime's relay), not the provider."""
|
|
701
|
+
import ipaddress
|
|
702
|
+
import urllib.parse
|
|
703
|
+
host = urllib.parse.urlsplit((endpoints.load().get(via or "") or {}).get("base") or "").hostname or ""
|
|
704
|
+
try:
|
|
705
|
+
return ipaddress.ip_address(host).is_private
|
|
706
|
+
except ValueError:
|
|
707
|
+
return host == "localhost"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def measure(rows: list[dict]) -> dict:
|
|
711
|
+
"""One model on one set, from each item's latest answer (module docstring)."""
|
|
712
|
+
from run import uptime_class
|
|
713
|
+
scored, fails = [], collections.Counter()
|
|
714
|
+
for r in rows:
|
|
715
|
+
if (s := _scored(r)) is None:
|
|
716
|
+
fails[uptime_class(r["error"], "error", _private(r.get("via")))[0] if r.get("error") else "no answer"] += 1
|
|
717
|
+
else:
|
|
718
|
+
scored.append((r, s))
|
|
719
|
+
secs = [r["seconds"] for r in rows if isinstance(r.get("seconds"), (int, float))]
|
|
720
|
+
out = {"asked": len(rows), "answered": len(scored), "correct": sum(s["right"] for _, s in scored),
|
|
721
|
+
"failures": dict(fails), "seconds": round(statistics.median(secs), 2) if secs else None,
|
|
722
|
+
"last": max((r.get("at") or "" for r in rows), default="")}
|
|
723
|
+
n = len(scored)
|
|
724
|
+
if not n:
|
|
725
|
+
return out
|
|
726
|
+
mean = statistics.fmean
|
|
727
|
+
out.update(accuracy=round(out["correct"] / n, 3), brier=round(mean(s["brier"] for _, s in scored), 4),
|
|
728
|
+
log_loss=round(mean(-math.log(max(s["p_true"], 1e-6)) for _, s in scored), 4))
|
|
729
|
+
bins: dict[int, list[dict]] = collections.defaultdict(list)
|
|
730
|
+
for _, s in scored:
|
|
731
|
+
bins[min(int(s["said"] * 5), 4)].append(s)
|
|
732
|
+
out["calibration"] = [{"from": b / 5, "n": len(v), "said": round(mean(x["said"] for x in v), 3),
|
|
733
|
+
"right": round(mean(x["right"] for x in v), 3)} for b, v in sorted(bins.items())]
|
|
734
|
+
out["ece"] = round(sum(len(v) / n * abs(mean(x["said"] for x in v) - mean(x["right"] for x in v))
|
|
735
|
+
for v in bins.values()), 4)
|
|
736
|
+
sure = [s for _, s in scored if s["conf"] >= CONFIDENT]
|
|
737
|
+
out["confident"] = {"cutoff": CONFIDENT, "coverage": round(len(sure) / n, 3),
|
|
738
|
+
"accuracy": round(mean(s["right"] for s in sure), 3) if sure else None}
|
|
739
|
+
if not isinstance(scored[0][0].get("label"), bool):
|
|
740
|
+
f1 = []
|
|
741
|
+
for c in {r["label"] for r, _ in scored} | {s["pred"] for _, s in scored if s["pred"] is not None}:
|
|
742
|
+
tp = sum(r["label"] == c and s["pred"] == c for r, s in scored)
|
|
743
|
+
miss = sum((r["label"] == c) != (s["pred"] == c) for r, s in scored)
|
|
744
|
+
f1.append(2 * tp / (2 * tp + miss) if tp + miss else 0.0)
|
|
745
|
+
out["macro_f1"] = round(mean(f1), 3)
|
|
746
|
+
return out
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def summarise(sets: dict | None = None) -> dict:
|
|
750
|
+
"""metrics/decide-bench.json from the log: every model on every set it was asked."""
|
|
751
|
+
latest: dict[tuple, dict] = {}
|
|
752
|
+
for r in log_rows():
|
|
753
|
+
if r.get("use") == "bench" and r.get("set") in SETS and r.get("item"):
|
|
754
|
+
latest[(r.get("alias"), r["set"], r["item"])] = r # the log is in time order
|
|
755
|
+
per: dict[tuple, list] = collections.defaultdict(list)
|
|
756
|
+
for (a, s, _), r in latest.items():
|
|
757
|
+
per[(a, s)].append(r)
|
|
758
|
+
models: dict[str, dict] = {}
|
|
759
|
+
for (a, s), rows in sorted(per.items()):
|
|
760
|
+
models.setdefault(a, {})[s] = measure(rows)
|
|
761
|
+
old = {s: v for s, v in (results().get("sets") or {}).items() if s in SETS}
|
|
762
|
+
info = {s: {"kind": KINDS[s], "size": len(v["items"]),
|
|
763
|
+
"labels": dict(collections.Counter(str(i["label"]) for i in v["items"]))}
|
|
764
|
+
for s, v in (sets or {}).items()}
|
|
765
|
+
out = {"updated": dt.datetime.now().astimezone().isoformat(timespec="seconds"), "confident": CONFIDENT,
|
|
766
|
+
"min_items": MIN_ITEMS, "sets": {**old, **info}, "models": models}
|
|
767
|
+
import locked
|
|
768
|
+
locked.write_atomic(RESULTS, json.dumps(out, indent=1, ensure_ascii=False) + "\n")
|
|
769
|
+
return out
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def bench(per: int = 1, gap: float = 30, only_sets=None, only_models=None, dry: bool = False,
|
|
773
|
+
sleep=time.sleep, out=print) -> dict:
|
|
774
|
+
"""One benchmark pass (module docstring). Returns the summary, or the
|
|
775
|
+
plan {alias: [(set, item id)]} on a dry run."""
|
|
776
|
+
import model_roles
|
|
777
|
+
sets = build_sets(only_sets)
|
|
778
|
+
held = set(model_roles.load()["ad_hoc"])
|
|
779
|
+
roster = {a: d for a, d in load().items() if a not in held and (not only_models or a in only_models)}
|
|
780
|
+
last: dict[tuple, float] = {}
|
|
781
|
+
for r in log_rows():
|
|
782
|
+
if r.get("use") == "bench":
|
|
783
|
+
last[(r.get("alias"), r.get("set"), r.get("item"))] = r.get("t") or 0
|
|
784
|
+
queue: dict[str, list] = {}
|
|
785
|
+
for a, d in roster.items():
|
|
786
|
+
lanes = []
|
|
787
|
+
for s, v in sets.items():
|
|
788
|
+
if v["question"]["type"] in (d.get("question_types") or []) and v["items"]:
|
|
789
|
+
# The items it was asked longest ago (never, first); the id breaks ties, a fixed shuffle.
|
|
790
|
+
lanes.append([(s, i) for i in sorted(v["items"], key=lambda i: (last.get((a, s, i["id"]), 0), i["id"]))
|
|
791
|
+
][:per])
|
|
792
|
+
queue[a] = [lane[k] for k in range(per) for lane in lanes if k < len(lane)] # a set at a time, in turn
|
|
793
|
+
if dry:
|
|
794
|
+
for a, picks in queue.items():
|
|
795
|
+
out(f" {a:<22} {len(picks)} questions: " + ", ".join(f"{s}:{i['id'][:6]}" for s, i in picks))
|
|
796
|
+
return {a: [(s, i["id"]) for s, i in picks] for a, picks in queue.items()}
|
|
797
|
+
stopped: dict[str, str] = {}
|
|
798
|
+
made = 0
|
|
799
|
+
for k in range(max((len(p) for p in queue.values()), default=0)):
|
|
800
|
+
for a, picks in queue.items():
|
|
801
|
+
if a in stopped or k >= len(picks):
|
|
802
|
+
continue
|
|
803
|
+
s, item = picks[k]
|
|
804
|
+
if made:
|
|
805
|
+
sleep(gap)
|
|
806
|
+
made += 1
|
|
807
|
+
try:
|
|
808
|
+
answer = ask(a, item["state"], {s: sets[s]["question"]}, use="bench", retry=False,
|
|
809
|
+
meta={"set": s, "item": item["id"], "label": item["label"]})["answers"][s]
|
|
810
|
+
got = answer.get("choice", answer.get("noul"))
|
|
811
|
+
out(f" {a:<22} {s:<10} {item['id'][:6]} said {got!s:<12} truth {item['label']}")
|
|
812
|
+
except Exception as exc: # noqa: BLE001 -- one failure ends its run: a rate limit is not pressed
|
|
813
|
+
stopped[a] = error_text(exc)
|
|
814
|
+
out(f" {a:<22} {s:<10} {item['id'][:6]} FAILED, stopped: {stopped[a][:120]}")
|
|
815
|
+
out(f"{made} calls")
|
|
816
|
+
return summarise(sets)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def report(res: dict, out=print) -> None:
|
|
820
|
+
"""The results, a line per model and set."""
|
|
821
|
+
out(f"decision benchmark, {res.get('updated', 'never run')}")
|
|
822
|
+
for u in USES:
|
|
823
|
+
who, why = choose(u, res.get("models") or {})
|
|
824
|
+
out(f" {u:<11} -> {who}: {why}")
|
|
825
|
+
for s in SETS:
|
|
826
|
+
info = (res.get("sets") or {}).get(s, {})
|
|
827
|
+
out(f"\n{s} ({info.get('size', '?')} items)")
|
|
828
|
+
for a, m in sorted((res.get("models") or {}).items()):
|
|
829
|
+
r = m.get(s)
|
|
830
|
+
if not r:
|
|
831
|
+
continue
|
|
832
|
+
out(f" {a:<22} {r['correct']}/{r['asked']} right, {r['answered']} answered"
|
|
833
|
+
+ (f", acc {r['accuracy']:.2f}, Brier {r['brier']:.3f}, log loss {r['log_loss']:.2f}" if r.get("answered") else "")
|
|
834
|
+
+ (f", F1 {r['macro_f1']:.2f}" if r.get("macro_f1") is not None else "")
|
|
835
|
+
+ (f", {r['seconds']}s" if r.get("seconds") is not None else "")
|
|
836
|
+
+ (f", failures {r['failures']}" if r.get("failures") else ""))
|
|
194
837
|
|
|
195
838
|
|
|
196
839
|
def main() -> None:
|
|
197
840
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
841
|
+
if sys.argv[1:2] == ["bench"]:
|
|
842
|
+
ap = argparse.ArgumentParser(prog="decide.py bench", description="One paced decision benchmark pass.")
|
|
843
|
+
ap.add_argument("--per", type=int, default=1, help="questions per model per set")
|
|
844
|
+
ap.add_argument("--gap", type=float, default=30, help="seconds between calls")
|
|
845
|
+
ap.add_argument("--sets", default="", help=f"comma-separated, of {', '.join(SETS)}")
|
|
846
|
+
ap.add_argument("--models", default="", help="comma-separated aliases; all onboarded by default")
|
|
847
|
+
ap.add_argument("--new-errors", type=int, default=0, dest="new_errors",
|
|
848
|
+
help="first, ask about up to this many new unrecognised error shapes")
|
|
849
|
+
ap.add_argument("--dry-run", action="store_true", dest="dry")
|
|
850
|
+
args = ap.parse_args(sys.argv[2:])
|
|
851
|
+
if args.new_errors and not args.dry and classify_errors(args.new_errors, args.gap):
|
|
852
|
+
time.sleep(args.gap)
|
|
853
|
+
res = bench(args.per, args.gap, [s for s in args.sets.split(",") if s] or None,
|
|
854
|
+
[m for m in args.models.split(",") if m] or None, args.dry)
|
|
855
|
+
if not args.dry:
|
|
856
|
+
report(res)
|
|
857
|
+
return
|
|
858
|
+
if sys.argv[1:2] == ["results"]:
|
|
859
|
+
report(results())
|
|
860
|
+
return
|
|
198
861
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
199
862
|
ap.add_argument("alias", help="a key of config/decision-models.json")
|
|
200
863
|
ap.add_argument("state", help="the text being judged")
|
|
@@ -216,7 +879,7 @@ def main() -> None:
|
|
|
216
879
|
q = {"type": "score", "instructions": args.instructions,
|
|
217
880
|
"criteria": [o.strip() for o in args.score.split(",")]}
|
|
218
881
|
try:
|
|
219
|
-
print(json.dumps(ask(args.alias, args.state, {"q": q})["answers"]["q"], indent=1))
|
|
882
|
+
print(json.dumps(ask(args.alias, args.state, {"q": q}, use="cli")["answers"]["q"], indent=1))
|
|
220
883
|
except ValueError as exc:
|
|
221
884
|
sys.exit(str(exc))
|
|
222
885
|
except Exception as exc: # noqa: BLE001
|