@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/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(FILE.read_text(encoding="utf-8"))
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") -> 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")
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
- 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)
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).get("answers", {}):
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) -> 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, path=entry.get("path", "decisions"))
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