@wendellresearch-ai/benchwarmers 0.5.6 → 0.6.1

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