@wendellresearch-ai/benchwarmers 0.5.5 → 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/route.py CHANGED
@@ -101,8 +101,9 @@ from aliases import current as current_alias # noqa: E402
101
101
 
102
102
 
103
103
  def _json(path: pathlib.Path) -> dict:
104
+ import locked
104
105
  try:
105
- return json.loads(path.read_text(encoding="utf-8"))
106
+ return json.loads(locked.read_text(path)) # inside a render, from its snapshot
106
107
  except (OSError, ValueError):
107
108
  return {}
108
109
 
@@ -135,7 +136,14 @@ def full_tag_of(alias: str) -> str:
135
136
  name, else "?": a hub fronts many connections, and an untagged model
136
137
  behind it (Open WebUI stopped listing it) is not on a connection called
137
138
  `openwebui` with every other untagged one.
139
+
140
+ Worked out once per alias in a dashboard render, which asked 2,600 times.
138
141
  """
142
+ import locked
143
+ return locked.once(("full_tag_of", alias), lambda: _full_tag_of(alias))
144
+
145
+
146
+ def _full_tag_of(alias: str) -> str:
139
147
  from run import _registered_names
140
148
  import endpoints
141
149
  # Tags are keyed by the served name. A decision model is not in the llm
@@ -273,7 +281,7 @@ def suite_recall() -> dict[str, tuple[int, int, int]]:
273
281
  def panel(shape: str | None = None, large: bool = True,
274
282
  nominated: list[str] | None = None,
275
283
  members: list[str] | None = None, explorers: int = 2,
276
- avoid: set[str] | None = None) -> dict:
284
+ avoid: set[str] | None = None, stand_ins: int = 0) -> dict:
277
285
  """`large=True` means the task is a big one — most of ours are.
278
286
 
279
287
  `members` is a bench-gateway COLLECTION: models the caller always runs. With
@@ -282,7 +290,8 @@ def panel(shape: str | None = None, large: bool = True,
282
290
  never with a member or a member's family. `avoid` keeps models out of
283
291
  those automatic picks (the gateway passes the ones on a retiring alert, so
284
292
  a seat is not spent on a model already known to be failing); a nomination
285
- still seats one.
293
+ still seats one. `stand_ins` more are picked the same way, for members out
294
+ of quota (the gateway); fewer come back if the pool runs out.
286
295
 
287
296
  `nominated` names models the operator wants on the panel today, and they
288
297
  take EXPLORER seats — never the primary or the second.
@@ -469,6 +478,12 @@ def panel(shape: str | None = None, large: bool = True,
469
478
 
470
479
  explorer, explorer2 = seats[0], seats[1]
471
480
 
481
+ standing_in = []
482
+ while len(standing_in) < stand_ins and (pick := stalest()):
483
+ chosen.add(pick["model"])
484
+ seated_families.add(pick["family"])
485
+ standing_in.append(pick)
486
+
472
487
  # RESERVE: the fallback when the panel meets a rate limit.
473
488
  #
474
489
  # This replaces the hard rule that seats must sit on different
@@ -489,7 +504,7 @@ def panel(shape: str | None = None, large: bool = True,
489
504
  key=lambda r: (r["found"], paid(r)), default=None)
490
505
 
491
506
  return {"primary": primary, "second": second, "explorer": explorer,
492
- "explorer2": explorer2, "reserve": reserve,
507
+ "explorer2": explorer2, "reserve": reserve, "stand_ins": standing_in,
493
508
  "considered": len(rows),
494
509
  "untried": sum(1 for r in rows if not r["offloads"]),
495
510
  "contenders": len(contenders),
@@ -6,7 +6,8 @@
6
6
  RELEASE-PLAN.md Phase 3. The checks used to exist only as Windows scheduled
7
7
  tasks (scripts/schedule.ps1), which macOS, Linux and Docker do not have. serve.py
8
8
  starts this in a background thread; the jobs below are the same seven, at the
9
- same times, with the same arguments (schedule.ps1 explains each choice).
9
+ same times, with the same arguments (schedule.ps1 explains each choice), and
10
+ since 2026-11-08 the decision benchmark, which has no Windows task.
10
11
 
11
12
  MODE, from config/scheduler.json:
12
13
  absent / "off" nothing runs. A fresh install spends no model calls until
@@ -55,6 +56,11 @@ JOBS = {
55
56
  "perf": (["perf/run.py", "-n", "3", "--timeout", "90", "--client", "builtin"], [(0, "07:00")]),
56
57
  "capabilities": (["metrics/probe_caps.py", "--gap", "30"], [(6, "07:00")]),
57
58
  "suite": (["suite/run.py", "--next"], [(None, t) for t in ("03:00", "09:00", "15:00", "21:00")]),
59
+ # Not in schedule.ps1 (2026-11-08). One question per model per set, 30 s
60
+ # apart round-robin: most decision models are OpenRouter `:free`, whose
61
+ # daily budget the suite and the probe share (metrics/decide.py).
62
+ "decisions": (["metrics/decide.py", "bench", "--per", "1", "--gap", "30", "--new-errors", "3"],
63
+ [(None, "04:30")]),
58
64
  }
59
65
  # Each mode as the Settings page names and explains it (the docstring above).
60
66
  MODES = {"off": ("Off", "Nothing runs. A fresh install spends no model calls until someone opts in."),
@@ -77,6 +83,9 @@ ABOUT = {
77
83
  " per model, spaced out.",
78
84
  "suite": "Scores one paid model on the review suite every six hours: up to six tasks it has not"
79
85
  " answered yet. These scores are what the router ranks by.",
86
+ "decisions": "Measures the decision models: one labelled question per model on each set (error classes,"
87
+ " collections, verdicts), 30 s apart, stopping a model at its first failure; and asks"
88
+ " about up to three new unrecognised error shapes. The gateway's picks ask the best one.",
80
89
  }
81
90
 
82
91
 
@@ -213,6 +222,7 @@ def _selftest() -> None:
213
222
  assert next_due(JOBS["perf"][1], mon) == dt.datetime(2026, 10, 19, 7, 0)
214
223
  assert next_due(JOBS["perf"][1], mon.replace(hour=8)) == dt.datetime(2026, 10, 26, 7, 0)
215
224
  assert next_due(JOBS["capabilities"][1], mon) == dt.datetime(2026, 10, 25, 7, 0)
225
+ assert next_due(JOBS["decisions"][1], mon) == dt.datetime(2026, 10, 19, 4, 30) and set(ABOUT) == set(JOBS)
216
226
  conf = {"mode": "log-only", "jobs": {n: n == "availability" for n in JOBS}}
217
227
  state: dict = {}
218
228
  global _log
@@ -93,17 +93,30 @@ FREELLMAPI_TO_MODELSDEV = {
93
93
  def fetch(name: str, force: bool = False) -> dict:
94
94
  """A feed, from the cache when it is under a day old."""
95
95
  path = CACHE / f"{name}.json"
96
- if not force and path.exists() and time.time() - path.stat().st_mtime < MAX_AGE_S:
96
+ age = age_s(name)
97
+ if not force and age is not None and age < MAX_AGE_S:
97
98
  return json.loads(path.read_text(encoding="utf-8"))
98
99
  req = urllib.request.Request(FEEDS[name], headers={"User-Agent": "benchwarmers"})
99
100
  with urllib.request.urlopen(req, timeout=90) as r:
100
101
  data = (json.load(r) if not FEEDS[name].endswith(".xml") else
101
102
  {"slugs": sorted(set(re.findall(r"/model/([^<\"/]+)<", r.read().decode("utf-8"))))})
103
+ store(name, data)
104
+ return data
105
+
106
+
107
+ def store(name: str, data: dict) -> None:
108
+ """Write one cached list whole, never half."""
102
109
  CACHE.mkdir(parents=True, exist_ok=True)
110
+ path = CACHE / f"{name}.json"
103
111
  tmp = path.with_suffix(".part")
104
112
  tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
105
113
  tmp.replace(path)
106
- return data
114
+
115
+
116
+ def age_s(name: str) -> float | None:
117
+ """Seconds since a cached list was written; None if it never was."""
118
+ path = CACHE / f"{name}.json"
119
+ return time.time() - path.stat().st_mtime if path.exists() else None
107
120
 
108
121
 
109
122
  def cached(name: str) -> dict:
@@ -1,14 +1,87 @@
1
- """python metrics/test_decide.py — error parsing and the question-type guard, no network."""
1
+ """python metrics/test_decide.py — the decision client and its log, the benchmark's
2
+ sets, measures and pass, who is asked, the error-shape cache and the role
3
+ suggestion. No network: urlopen is a stub. BENCHWARMERS_HOME is a temp folder,
4
+ set before anything is imported."""
2
5
  import io
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import shutil
3
10
  import sys
11
+ import tempfile
4
12
  import urllib.error
5
13
 
6
- sys.path.insert(0, __file__.rsplit("\\", 1)[0].rsplit("/", 1)[0])
14
+ TMP = pathlib.Path(tempfile.mkdtemp(prefix="bench-decide-"))
15
+ for sub in ("config", "metrics", "perf", "runs/gateway", "llm"):
16
+ (TMP / sub).mkdir(parents=True)
17
+ os.environ.update(BENCHWARMERS_HOME=str(TMP), LLM_USER_PATH=str(TMP / "llm"), BENCHWARMERS_KEY_HUB="sk-test")
18
+ HERE = pathlib.Path(__file__).resolve().parent
19
+ sys.path[:0] = [str(HERE), str(HERE.parent / "suite")]
20
+
21
+
22
+ def write(path: str, data) -> None:
23
+ text = data if isinstance(data, str) else json.dumps(data)
24
+ (TMP / path).write_text(text, encoding="utf-8")
25
+
26
+
27
+ def jsonl(path: str, rows: list[dict]) -> None:
28
+ write(path, "".join(json.dumps(r) + "\n" for r in rows))
29
+
30
+
31
+ write("config/endpoints.json", {"hub": {"base": "http://127.0.0.1:9/api", "key": "hub", "default": True,
32
+ "decisions": "http://127.0.0.1:9/openai/{path}"}})
33
+ # dm2 first: before any result it is the one asked (the first that takes choice, as before),
34
+ # except per request, where its :free budget is spared while dm1 can answer.
35
+ MODELS = {
36
+ "dm2": {"served": "vendor/d2:free", "via": "hub", "question_types": ["noul", "choice", "score"]},
37
+ "dm1": {"served": "d1", "via": "hub", "path": "systemone", "question_types": ["noul", "choice", "score"]},
38
+ "yesno": {"served": "y", "via": "hub", "question_types": ["noul"]},
39
+ "dear": {"served": "dd", "via": "hub", "question_types": ["noul", "choice"]}}
40
+ write("config/decision-models.json", MODELS)
41
+ write("config/model-roles.json", {"models": {"m1": "general"}, "ad_hoc": ["dear"]})
42
+ write("config/collections.json", {
43
+ "core": {"members": ["m1"], "description": "General second opinions.", "tags": ["general"], "shape": "general"},
44
+ "fast": {"members": ["m1"], "description": "", "tags": ["fast"], "shape": "quick"},
45
+ "vision": {"members": ["m1"], "description": "Photos with questions.", "tags": ["vision"]},
46
+ "dear": {"members": ["m1"], "description": "Costly.", "ad_hoc": True}})
47
+ BUSY = "Error: Error code: 503 - The model is temporarily unavailable due to high demand. (Trace ID: 3fa2c9d81b)"
48
+ QUOTA = "Error: Error code: 429 - Rate limit exceeded: free-models-per-day. Add 10 credits"
49
+ ODD = "Error: the gremlins ate it"
50
+ jsonl("runs/gateway/calls.jsonl", [
51
+ {"panel": "p1", "model": "bench/core", "shape": "general", "shape_by": "set on the collection", "system": "",
52
+ "prompt": "Review this function for bugs.",
53
+ "seats": [{"call": "p1.member1", "status": "ok", "answer": "It double-frees on line 3.", "error": ""},
54
+ {"call": "p1.explorer", "status": "error", "answer": "", "error": BUSY},
55
+ {"call": "p1.explorer2", "status": "empty", "answer": "", "error": ""}]},
56
+ {"panel": "p2", "model": "bench/fast", "shape": "quick", "shape_by": "set on the collection", "system": "Be brief.",
57
+ "prompt": "Spam or not: WIN A PRIZE", "seats": [{"call": "p2.member1", "status": "error", "answer": "", "error": QUOTA},
58
+ {"call": "p2.member2", "status": "error", "answer": "", "error": ODD}]},
59
+ {"panel": "p3", "model": "bench/vision", "shape": "vision", "shape_by": "the request carries images",
60
+ "prompt": "What is printed here? [image: data URL, 12 KB]",
61
+ "seats": [{"call": "p3.member1", "status": "ok", "answer": "A cat.", "error": ""}]},
62
+ # Chosen by a decision model: labels nothing.
63
+ {"panel": "p4", "model": "bench/core", "shape": "general", "shape_by": "set on the collection",
64
+ "prompt": "A request the gateway's pick sent here.", "picked": {"collection": "bench/core"}, "seats": []},
65
+ {"panel": "p5", "model": "bench/dear", "shape": "general", "shape_by": "no explorer seats to fill",
66
+ "prompt": "An ad hoc collection's request.", "seats": []},
67
+ # The shape a decision model chose is not the collection's own.
68
+ {"panel": "p6", "model": "bench/core", "shape": "review", "shape_by": "dm2, 0.99", "prompt": "Is this lock safe?",
69
+ "seats": [{"call": "p6.member1", "status": "ok", "answer": "Yes.", "error": ""}]}])
70
+ jsonl("metrics/offloads.jsonl", [
71
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": True, "note": "right", "call": "p1.member1"},
72
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": False, "note": "a cat? no", "call": "p3.member1"},
73
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": False, "note": "it is not", "call": "p6.member1"},
74
+ {"date": "2026-11-08", "model": "m1", "task": "t", "useful": True, "note": "ui test: in-place verdict",
75
+ "call": "p1.explorer"}])
76
+ write("perf/history.csv", "timestamp,model,prompt,answered,error,detail\n2026-11-08T05:00:00,m1,tiny,0,1,<<TIMED OUT>>\n")
77
+
7
78
  import decide # noqa: E402
8
79
 
80
+ # ---- error text, and the type guard -----------------------------------------------
81
+
9
82
 
10
- def http_error(body: bytes) -> urllib.error.HTTPError:
11
- return urllib.error.HTTPError("http://x", 400, "bad", {}, io.BytesIO(body))
83
+ def http_error(body: bytes, code: int = 400) -> urllib.error.HTTPError:
84
+ return urllib.error.HTTPError("http://x", code, "bad", {}, io.BytesIO(body))
12
85
 
13
86
 
14
87
  # Every shape providers send must come back as text, never raise.
@@ -18,12 +91,207 @@ assert "passthrough" in decide.error_text(http_error(b'{"detail": "Direct API pa
18
91
  assert decide.error_text(http_error(b'{"error": null, "message": "down"}')).startswith("400")
19
92
  assert decide.error_text(http_error(b"not json")) == "400"
20
93
 
21
- # ask() refuses a type the entry does not list, before any call is made.
22
- decide.load = lambda: {"respan": {"served": "r", "via": "nowhere", "question_types": ["noul"]}}
94
+ # ask() refuses a type the entry does not list, before any call is made (and logs nothing).
23
95
  try:
24
- decide.ask("respan", "s", {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}})
96
+ decide.ask("yesno", "s", {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}})
25
97
  except ValueError as exc:
26
98
  assert "does not take choice" in str(exc)
27
99
  else:
28
100
  raise AssertionError("choice should have been refused")
101
+ assert not decide.LOG.exists()
102
+
103
+ # ---- the stub provider: dm1 answers the truth, dm2 is rate limited, the rest say yes / the first option --
104
+
105
+ SEEN: list[dict] = []
106
+ TRUTH: dict[str, object] = {}
107
+
108
+
109
+ def urlopen(request, timeout=0):
110
+ body = json.loads(request.data)
111
+ SEEN.append({"url": request.full_url, **body})
112
+ if body["model"] == "vendor/d2:free":
113
+ raise http_error(b'{"error": {"message": "Rate limit exceeded: free-models-per-min"}}', 429)
114
+ name, q = next(iter(body["questions"].items()))
115
+ truth = TRUTH.get(body["state"]) if body["model"] == "d1" else None
116
+ if q["type"] == "noul":
117
+ p = 0.9 if truth in (True, None) else 0.1
118
+ answer = {"type": "noul", "noul": p}
119
+ else:
120
+ options = list(q["criteria"])
121
+ pick = truth if truth in options else options[0]
122
+ probs = {o: (0.9 if o == pick else 0.1 / (len(options) - 1)) for o in options}
123
+ answer = {"type": "choice", "choice": pick, "probabilities": probs, "confidence": 0.85}
124
+ return io.BytesIO(json.dumps({"answers": {name: answer}}).encode())
125
+
126
+
127
+ decide.urllib.request.urlopen = urlopen
128
+
129
+ # ---- every call is logged, and an error's body survives the log -------------------------------
130
+
131
+ out = decide.ask("dm1", "hello", {"q": decide.PROBES["noul"]}, use="test")
132
+ assert out["answers"]["q"]["noul"] == 0.9 and SEEN[-1]["url"].endswith("/openai/systemone")
133
+ row = decide.log_rows()[-1]
134
+ assert (row["alias"], row["served"], row["use"], row["types"], row["chars"]) == ("dm1", "d1", "test", {"q": "noul"}, 5)
135
+ assert row["answers"]["q"]["noul"] == 0.9 and row["error"] == "" and isinstance(row["seconds"], float), row
136
+ before = len(SEEN)
137
+ try:
138
+ decide.ask("dm2", "hello", {"q": decide.PROBES["noul"]}, use="test", retry=False)
139
+ except urllib.error.HTTPError as exc:
140
+ assert "free-models-per-min" in decide.error_text(exc) # read once by the log, kept for the caller
141
+ else:
142
+ raise AssertionError("dm2 should have failed")
143
+ assert len(SEEN) == before + 1, "retry=False: a 429 is not retried"
144
+ row = decide.log_rows()[-1]
145
+ assert row["alias"] == "dm2" and row["answers"] is None and "429 Rate limit" in row["error"], row
146
+
147
+ # ---- shapes: nothing private, the status kept -------------------------------------------------
148
+
149
+ s = decide.shape("Error code: 429 - key sk-or-v1-abcdef0123456789 for a.b@example.com at"
150
+ " https://x.example/v1?key=secret, trace 3fa2c9d81b, retry in 37 s")
151
+ assert "429" in s and "<email>" in s and "<url>" in s and "retry in N s" in s, s
152
+ assert not any(w in s for w in ("secret", "example.com", "abcdef0123", "3fa2c9d81b")), s
153
+ assert decide.shape("word " * 400).count("…") == 1 # start and end kept
154
+
155
+ # ---- the sets, from the data written above ----------------------------------------------------
156
+
157
+ sets = decide.build_sets()
158
+ labels = {k: sorted(str(i["label"]) for i in v["items"]) for k, v in sets.items()}
159
+ assert labels["errors"] == ["busy", "empty", "quota", "timeout"], labels # the gremlins: unrecognised
160
+ assert labels["collection"] == ["core", "core", "fast", "vision"], labels # not the pick's, not ad hoc
161
+ assert labels["useful"] == ["False", "True"], labels # text only, no ui test
162
+ assert set(sets) == {"errors", "collection", "useful"} # no shape set
163
+ assert set(sets["collection"]["question"]["criteria"]) == {"core", "fast", "vision"}
164
+ assert sets["collection"]["question"]["criteria"]["fast"] == "No description. Tags: fast."
165
+ assert "Be brief.\n\nSpam or not" in next(i["state"] for i in sets["collection"]["items"] if i["label"] == "fast")
166
+ assert next(i for i in sets["useful"]["items"] if i["label"])["state"].startswith("THE REQUEST\n\nReview this")
167
+ assert not any("3fa2c9d81b" in i["state"] for i in sets["errors"]["items"])
168
+ for v in sets.values():
169
+ for i in v["items"]:
170
+ TRUTH[i["state"]] = i["label"]
171
+
172
+ # ---- who is asked, before any result: the first onboarded that takes the type, not ad hoc ------
173
+
174
+ assert decide.best("errors") == "dm2" and decide.best("useful") == "dm2" and decide.best("role") == "dm2"
175
+ # Per request (the gateway's picks, the notice check) the :free dm2 is spared while dm1 takes the question.
176
+ assert decide.best("shape") == "dm1" and decide.best("collection") == "dm1" and decide.best("notice") == "dm1"
177
+ who, why = decide.choose("shape")
178
+ assert who == "dm1" and "the default" in why and ":free models spared" in why and "collection set" in why, why
179
+ write("config/decision-models.json", {"dm2": MODELS["dm2"]})
180
+ assert decide.best("shape") == "dm2" # the only one: the free budget it is
181
+ write("config/decision-models.json", {"dear": {"served": "dd", "via": "hub", "question_types": ["choice"]}})
182
+ assert decide.best("shape") is None # ad hoc only: nobody
183
+ answer, by, why = decide.pick("shape", "x", decide.SHAPES, "test")
184
+ assert (answer, by, why) == ({}, "", "no decision model onboarded")
185
+ write("config/decision-models.json", MODELS)
186
+
187
+ # ---- the measures, by hand ----------------------------------------------------------------------
188
+
189
+ choice = [
190
+ {"set": "s", "label": "a", "seconds": 1.0, "at": "1",
191
+ "answers": {"s": {"choice": "a", "probabilities": {"a": 0.9, "b": 0.1}, "confidence": 0.8}}},
192
+ {"set": "s", "label": "a", "seconds": 3.0, "at": "2",
193
+ "answers": {"s": {"choice": "b", "probabilities": {"a": 0.3, "b": 0.7}, "confidence": 0.4}}},
194
+ {"set": "s", "label": "a", "seconds": 2.0, "at": "3", "answers": None, "error": QUOTA}]
195
+ m = decide.measure(choice)
196
+ assert (m["asked"], m["answered"], m["correct"], m["accuracy"]) == (3, 2, 1, 0.5), m
197
+ assert (m["brier"], m["log_loss"], m["ece"], m["macro_f1"], m["seconds"]) == (0.5, 0.6547, 0.4, 0.333, 2.0), m
198
+ assert m["confident"] == {"cutoff": 0.6, "coverage": 0.5, "accuracy": 1.0} and m["failures"] == {"quota": 1}, m
199
+ assert [(b["from"], b["n"], b["right"]) for b in m["calibration"]] == [(0.6, 1, 0.0), (0.8, 1, 1.0)], m
200
+ noul = [{"set": "u", "label": True, "answers": {"u": {"noul": 0.8}}},
201
+ {"set": "u", "label": False, "answers": {"u": {"noul": 0.7}}}]
202
+ m = decide.measure(noul)
203
+ assert (m["accuracy"], m["brier"], "macro_f1" in m) == (0.5, 0.265, False), m
204
+ assert decide.measure([{"set": "u", "label": True, "answers": {"u": {"noul": "?"}}}])["failures"] == {"no answer": 1}
205
+ # A null confidence is no confidence: the answer still counts, at the probability it gave its choice.
206
+ m = decide.measure([{"set": "s", "label": "a", "answers": {"s": {"choice": "a", "probabilities": {"a": 0.9, "b": 0.1},
207
+ "confidence": None}}}])
208
+ assert m["answered"] == 1 and m["confident"]["coverage"] == 1.0, m
209
+ # An endpoint gone from the config is a failed call, logged like any other.
210
+ try:
211
+ decide.call("gone", "x", "s", {"q": decide.PROBES["noul"]}, use="test")
212
+ except KeyError:
213
+ pass
214
+ assert decide.log_rows()[-1]["alias"] == "x" and "KeyError" in decide.log_rows()[-1]["error"]
215
+
216
+ # ---- a dry run asks nobody; a pass is round-robin, paced, and stops a model at its first failure --
217
+
218
+ plan = decide.bench(per=1, dry=True, out=lambda *_: None)
219
+ assert set(plan) == {"dm2", "dm1", "yesno"}, plan # never the ad hoc one
220
+ assert [s for s, _ in plan["yesno"]] == ["useful"] and [s for s, _ in plan["dm1"]] == list(decide.SETS), plan
221
+ SEEN.clear()
222
+ slept: list[float] = []
223
+ res = decide.bench(per=1, gap=7, sleep=slept.append, out=lambda *_: None)
224
+ assert [q["model"] for q in SEEN] == ["vendor/d2:free", "d1", "y", "d1", "d1"], [q["model"] for q in SEEN]
225
+ assert slept == [7] * 4, slept
226
+ assert set(res["models"]["dm1"]) == set(decide.SETS) and all(r["correct"] == 1 for r in res["models"]["dm1"].values())
227
+ assert res["models"]["dm2"]["errors"]["failures"] == {"rate": 1}, res["models"]["dm2"]
228
+ assert res["sets"]["errors"]["size"] == 4 and res["min_items"] == decide.MIN_ITEMS, res
229
+ assert json.loads(decide.RESULTS.read_text(encoding="utf-8"))["models"]["yesno"]["useful"]["correct"] == 1
230
+ # One item each is under MIN_ITEMS: nobody is ranked, the defaults stand.
231
+ assert decide.best("errors") == "dm2" and "none has 8 items on the errors set" in decide.choose("errors")[1]
232
+ decide.MIN_ITEMS = 1
233
+ assert decide.best("errors") == "dm1" and decide.best("role") == "dm1" # 1 of 1 beats 0 of 1
234
+ assert decide.choose("errors")[1] == "best of 2 ranked on the errors set: 1 right of 1 asked"
235
+ assert decide.best("shape") == "dm1" and decide.best("useful") == "dm1" # a tie with yesno: config order
236
+ decide.MIN_ITEMS = 8
237
+ bench_rows = [r for r in decide.log_rows() if r.get("use") == "bench"]
238
+ assert len(bench_rows) == 5 and all(r["item"] and "label" in r for r in bench_rows), bench_rows
239
+ # A second pass asks each model the items it was asked longest ago: another collection item now.
240
+ plan2 = decide.bench(per=1, dry=True, out=lambda *_: None)
241
+ assert dict(plan2["dm1"])["collection"] != dict(plan["dm1"])["collection"], (plan, plan2)
242
+
243
+ # ---- the pick asks the best, and logs the use ----------------------------------------------------
244
+
245
+ answer, by, why = decide.pick("shape", "Review this function for bugs.", decide.SHAPES, "pick_shape")
246
+ assert by == "dm1" and not why and answer["choice"] in decide.SHAPES
247
+ assert decide.log_rows()[-1]["use"] == "pick_shape"
248
+
249
+ # ---- unrecognised error shapes: asked once each, cached, read without a call ----------------------
250
+
251
+ import run # noqa: E402
252
+
253
+ decide.MIN_ITEMS = 1 # dm1 ranked first on errors from here
254
+ SEEN.clear()
255
+ TRUTH[decide.shape(ODD)] = "unreachable" # what the stub's dm1 will say
256
+ assert run.uptime_class(ODD) == ("unrecognised", "nothing matched")
257
+ assert decide.classify_errors(limit=3, sleep=slept.append) == 1 and len(SEEN) == 1 # only the gremlins
258
+ assert SEEN[0]["state"] == decide.shape(ODD) and SEEN[0]["model"] == "d1"
259
+ assert decide.cached_error_class(ODD) == ("unreachable", "decided by dm1 at 0.85"), decide.cached_error_class(ODD)
260
+ assert decide.classify_errors(limit=3) == 0 # asked once per shape
261
+ assert decide.cached_error_class("something else entirely") is None
262
+ # uptime_class reads the decision (no call), the benchmark's labels never do.
263
+ assert run.uptime_class(ODD) == ("unreachable", "decided by dm1 at 0.85") and len(SEEN) == 1
264
+ assert run.uptime_class(ODD, decided=False) == ("unrecognised", "nothing matched")
265
+ assert decide.build_sets(["errors"])["errors"]["items"] == sets["errors"]["items"]
266
+ # A decided `empty` is never used: an error is not an answer, and empty counts as up.
267
+ import locked # noqa: E402
268
+ locked.update(decide.DECIDED, lambda d: d["errors"][decide.shape(ODD)].__setitem__("class", "empty"))
269
+ assert run.uptime_class(ODD)[0] == "unrecognised"
270
+ locked.update(decide.DECIDED, lambda d: d["errors"][decide.shape(ODD)].__setitem__("class", "unreachable"))
271
+
272
+ # ---- the provider-notice fallback: asked only for a seat's short reply that looks like one ----------
273
+
274
+ SEEN.clear()
275
+ NOTE = "Your plan's balance is low; top up soon to keep going."
276
+ assert run.provider_notice("ready") is None and run.provider_notice("ready", ask=True) is None
277
+ assert run.provider_notice("The limit here is " + "x " * 200, ask=True) is None # long: an answer
278
+ assert run.provider_notice(NOTE) is None and not SEEN # a page or a score never asks
279
+ assert run.provider_notice("you have run out of credits") == "run out of credits" and not SEEN # the phrases first
280
+ assert run.provider_notice(NOTE, ask=True) == "decided by dm1 at 0.90" and len(SEEN) == 1
281
+ assert SEEN[0]["model"] == "d1" and SEEN[0]["questions"]["notice"]["type"] == "noul" # per request: not :free
282
+ assert decide.log_rows()[-1]["use"] == "notice"
283
+ assert run.provider_notice(NOTE, ask=True) == "decided by dm1 at 0.90" and len(SEEN) == 1 # kept: asked once
284
+ assert run.provider_notice(NOTE) == "decided by dm1 at 0.90" # and read anywhere
285
+ assert run.failure_class(NOTE) == "entitlement"
286
+
287
+ # ---- a role suggestion is kept, never set --------------------------------------------------------
288
+
289
+ hint = decide.suggest_role("m2", "model: vendor/m2\ncontext window: 8192\ndescription: A small coder.")
290
+ assert (hint["role"], hint["confidence"], hint["by"]) == ("general", 0.85, "dm1"), hint # the stub's first option
291
+ assert "decision" not in SEEN[-1]["questions"]["role"]["criteria"], SEEN[-1]
292
+ kept = json.loads(decide.DECIDED.read_text(encoding="utf-8"))
293
+ assert kept["roles"]["m2"]["role"] == hint["role"] and "unreachable" == kept["errors"][decide.shape(ODD)]["class"]
294
+ assert json.loads((TMP / "config" / "model-roles.json").read_text(encoding="utf-8"))["models"] == {"m1": "general"}
295
+
296
+ shutil.rmtree(TMP, ignore_errors=True)
29
297
  print("ok")
package/metrics/wrong.py CHANGED
@@ -75,13 +75,30 @@ def judge(spec: dict, text: str, invented: list) -> tuple[str | None, bool]:
75
75
  return None, False
76
76
 
77
77
 
78
- def table() -> dict[str, dict]:
78
+ def _row() -> dict:
79
+ return {"answers": 0, "wrong": 0, "by": {k: [0, 0] for k in KINDS}, "reviews": 0, "review_false": 0,
80
+ "hunt": dict.fromkeys(HUNT, 0)}
81
+
82
+
83
+ def table(scored: dict[str, dict] | None = None) -> dict[str, dict]:
84
+ """scores(), or the `scored` the caller kept from it, with each model's
85
+ reviews from the offloads added."""
86
+ import copy
79
87
  import offload
88
+ out = collections.defaultdict(_row, copy.deepcopy(scores() if scored is None else scored))
89
+ for e in offload.entries():
90
+ if e.get("useful") is not None:
91
+ row = out[e["model"]]
92
+ row["reviews"] += 1
93
+ row["review_false"] += len(e.get("false") or [])
94
+ return dict(out)
95
+
96
+
97
+ def scores() -> dict[str, dict]:
98
+ """Every suite answer judged, by model: the costly part (it reads them all)."""
80
99
  import run
81
100
  from aliases import current
82
- out: dict[str, dict] = collections.defaultdict(
83
- lambda: {"answers": 0, "wrong": 0, "by": {k: [0, 0] for k in KINDS}, "reviews": 0, "review_false": 0,
84
- "hunt": dict.fromkeys(HUNT, 0)})
101
+ out: dict[str, dict] = collections.defaultdict(_row)
85
102
  for path in sorted(TASKS.glob("*/task.json")):
86
103
  spec = json.loads(path.read_text(encoding="utf-8"))
87
104
  # Reading images has its own record (serve.vision_rows): a trap taken
@@ -104,11 +121,6 @@ def table() -> dict[str, dict]:
104
121
  if wrong:
105
122
  row["wrong"] += 1
106
123
  row["by"][kind][0] += 1
107
- for e in offload.entries():
108
- if e.get("useful") is not None:
109
- row = out[e["model"]]
110
- row["reviews"] += 1
111
- row["review_false"] += len(e.get("false") or [])
112
124
  return dict(out)
113
125
 
114
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
@@ -49,6 +49,8 @@
49
49
  "repoqa/*.py",
50
50
  "repoqa/*.md",
51
51
  "ui/",
52
+ ".github/logo.svg",
53
+ ".github/logo-dark.svg",
52
54
  "examples/",
53
55
  "VERSION",
54
56
  "NOTICE",