@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.
@@ -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.6",
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",
@@ -49,8 +49,10 @@ from __future__ import annotations
49
49
  import csv
50
50
  import datetime as dt
51
51
  import json
52
+ import os
52
53
  import pathlib
53
54
  import sys
55
+ import threading
54
56
 
55
57
  ROOT = pathlib.Path(__file__).resolve().parent.parent
56
58
  sys.path.insert(0, str(ROOT / "metrics"))
@@ -201,11 +203,23 @@ def _current(alias: str, history: dict[str, str]) -> str:
201
203
  return alias
202
204
 
203
205
 
204
- def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
205
- """(perf rows, last non-perf answer per alias, restore date per alias)."""
206
- history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
206
+ # evidence() runs on every dashboard render, and re-read 13 MB of offloads and
207
+ # 1,900 saved answers each time: 1.2 s on the owner's data. Each part is read
208
+ # again only when its file changes.
209
+ _perf: tuple = (None, []) # ((alias history, history.csv's stat), its rows)
210
+ _outputs: dict[str, tuple] = {} # saved answer's path -> ((mtime, size), day it answered or None)
211
+
212
+
213
+ def _stat(path: pathlib.Path) -> tuple:
214
+ try:
215
+ st = path.stat()
216
+ except OSError:
217
+ return (str(path),)
218
+ return str(path), st.st_mtime_ns, st.st_size
219
+
220
+
221
+ def _perf_rows(path: pathlib.Path, history: dict[str, str]) -> list[dict]:
207
222
  rows = []
208
- path = DATA / "perf" / "history.csv"
209
223
  if path.exists():
210
224
  with path.open(encoding="utf-8", newline="") as fh:
211
225
  for r in csv.DictReader(fh):
@@ -215,28 +229,74 @@ def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
215
229
  "failure": r.get("failure") or "",
216
230
  "detail": r.get("detail") or ""})
217
231
  rows.sort(key=lambda r: r["date"])
232
+ return rows
233
+
234
+
235
+ _useful_lock = threading.Lock()
236
+ _useful_seen: dict = {"mark": {}, "history": None, "days": {}}
237
+
238
+
239
+ def _useful(path: pathlib.Path, history: dict[str, str]) -> dict[str, dt.date]:
240
+ """alias -> the last day a useful offload says it answered. Only the lines
241
+ added since the last call are read (the gateway adds some on every call),
242
+ all of them again after a rename."""
243
+ with _useful_lock:
244
+ s = _useful_seen
245
+ if s["history"] != history:
246
+ s.update(mark={}, history=history)
247
+ fresh, lines = locked.appended(path, s["mark"])
248
+ if fresh:
249
+ s["days"] = {}
250
+ days = s["days"]
251
+ for line in lines:
252
+ try:
253
+ o = json.loads(line)
254
+ except ValueError:
255
+ continue
256
+ if o.get("useful"):
257
+ alias, day = _current(o["model"], history), dt.date.fromisoformat(o["date"])
258
+ days[alias] = max(days.get(alias, day), day)
259
+ return dict(days)
260
+
261
+
262
+ def _saved_answers():
263
+ """(model, day) for each saved answer that was a real answer. The listing
264
+ brings each file's stat (free on Windows); a file is read again only when
265
+ it changes."""
266
+ from run import outcome
267
+ for folder in DATA.glob("suite/tasks/*/output"): # the saved answers are the install's data
268
+ try:
269
+ listing = list(os.scandir(folder))
270
+ except OSError:
271
+ continue
272
+ for e in listing:
273
+ if not e.name.endswith(".md"):
274
+ continue
275
+ st = e.stat()
276
+ hit = _outputs.get(e.path)
277
+ if hit is None or hit[0] != (st.st_mtime_ns, st.st_size):
278
+ text = pathlib.Path(e.path).read_text(encoding="utf-8", errors="replace")
279
+ hit = _outputs[e.path] = ((st.st_mtime_ns, st.st_size),
280
+ dt.date.fromtimestamp(st.st_mtime) if outcome(text) is None else None)
281
+ if hit[1]:
282
+ yield e.name[:-3], hit[1]
218
283
 
219
- answers: dict[str, dt.date] = {}
220
284
 
221
- def saw(alias: str, day: dt.date) -> None:
285
+ def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
286
+ """(perf rows, last non-perf answer per alias, restore date per alias).
287
+ The rows are shared between calls: read them, never change them."""
288
+ global _perf
289
+ history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
290
+ path = DATA / "perf" / "history.csv"
291
+ if _perf[0] != (key := (history, _stat(path))):
292
+ _perf = (key, _perf_rows(path, history))
293
+ rows = _perf[1]
294
+ offloads = DATA / "metrics" / "offloads.jsonl"
295
+ answers = _useful(offloads, history)
296
+ for alias, day in _saved_answers():
222
297
  alias = _current(alias, history)
223
298
  answers[alias] = max(answers.get(alias, day), day)
224
299
 
225
- offloads = DATA / "metrics" / "offloads.jsonl"
226
- for line in (offloads.read_text(encoding="utf-8").splitlines()
227
- if offloads.exists() else []):
228
- try:
229
- o = json.loads(line)
230
- except ValueError:
231
- continue
232
- if o.get("useful"):
233
- saw(o["model"], dt.date.fromisoformat(o["date"]))
234
-
235
- from run import outcome
236
- for out in DATA.glob("suite/tasks/*/output/*.md"): # the saved answers are the install's data
237
- if outcome(out.read_text(encoding="utf-8", errors="replace")) is None:
238
- saw(out.stem, dt.date.fromtimestamp(out.stat().st_mtime))
239
-
240
300
  restored: dict[str, dt.date] = {}
241
301
  changes = DATA / "config" / "changes.jsonl"
242
302
  for line in (changes.read_text(encoding="utf-8").splitlines()
@@ -403,6 +463,21 @@ def _selfcheck() -> None:
403
463
  refused = [row("pk", n, "signin") for n in (9, 5, 0)]
404
464
  p = plan(d, {"pk": "v/pk"}, {"v/pk"}, {}, refused, {}, {}, {"v/pk": "v"})
405
465
  assert not p["retire"] and not p["confirm"] and not p["pending"], p
466
+ # evidence() reads a file again when it changes, and only then
467
+ global DATA
468
+ import tempfile
469
+ with tempfile.TemporaryDirectory() as tmp:
470
+ DATA = pathlib.Path(tmp)
471
+ out = DATA / "suite" / "tasks" / "t" / "output" / "m.md"
472
+ out.parent.mkdir(parents=True)
473
+ out.write_text("a real answer, long enough to count as one: " * 2, encoding="utf-8")
474
+ log = DATA / "metrics" / "offloads.jsonl"
475
+ log.parent.mkdir()
476
+ log.write_text('{"model": "o", "date": "2026-10-01", "useful": true}\n', encoding="utf-8")
477
+ assert set(evidence()[1]) == {"m", "o"}
478
+ out.write_text("<<timed out>>", encoding="utf-8")
479
+ log.write_text("", encoding="utf-8")
480
+ assert evidence()[1] == {}
406
481
  print("auto_retire: rules hold")
407
482
 
408
483
 
@@ -21,7 +21,9 @@ WHAT IT DOES, AND WHY IN THIS ORDER
21
21
  3. **Register and sync**, then, with an Open WebUI hub, refresh the provider
22
22
  tags so the connection behind it is known — which is what the router uses
23
23
  to keep a panel independent. Without one the endpoint is the connection.
24
- Then refresh the catalog, so its details (context, tools, vision) show now.
24
+ Then refresh the catalog, so its details (context, tools, vision) show now,
25
+ and ask a decision model which role suits it from them: kept as a
26
+ suggestion with its confidence (metrics/decide.py suggest_role), never set.
25
27
  4. **Probe availability** before spending anything: two prompt sizes, two
26
28
  samples. A model that cannot answer "ready" will not answer a 46 KB task,
27
29
  and finding that out here costs four calls instead of six large ones.
@@ -185,6 +187,17 @@ def makes_images(alias: str, served_name: str) -> bool:
185
187
  return "image" in out if out else bool(IMAGE_NAMES.search(served_name))
186
188
 
187
189
 
190
+ def role_facts(alias: str, served_name: str, connection: str) -> str:
191
+ """What a decision model is told when asked for a new chat model's role:
192
+ its name, connection and catalog facts, the description last."""
193
+ import catalog
194
+ rec = catalog.info(alias)
195
+ facts = [f"{k.replace('_', ' ')}: {', '.join(map(str, v)) if isinstance(v, list) else v}"
196
+ for k in catalog.FIELDS if k != "description" and (v := rec.get(k)) not in (None, "", [])]
197
+ return "\n".join([f"model: {served_name}", f"connection: {connection}", *facts,
198
+ f"description: {rec.get('description') or 'none known'}"])
199
+
200
+
188
201
  def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
189
202
  return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
190
203
  capture_output=True, text=True, encoding="utf-8",
@@ -305,7 +318,13 @@ def main() -> None:
305
318
  lambda roles: roles.setdefault("models", {}).__setitem__(alias, "vision"),
306
319
  ensure_ascii=False)
307
320
  print(" role vision: a seat only for requests that carry images")
308
- run("scripts/sync_config.py", timeout=300)
321
+ # The llm CLI's copy of the registry, only while it is the client: the
322
+ # built-in client reads the data folder's, and choosing the llm CLI on
323
+ # Settings syncs it whole (serve.save_client).
324
+ import client as model_client
325
+ synced = model_client.chosen()[0] == "llm"
326
+ if synced:
327
+ run("scripts/sync_config.py", timeout=300)
309
328
  # Open WebUI's tags name the connection behind the hub; without a hub the
310
329
  # endpoint is the connection, and route.connection_of needs no tags.
311
330
  if endpoints.find_hub("openwebui"):
@@ -319,7 +338,8 @@ def main() -> None:
319
338
  catalog = run("metrics/catalog.py", timeout=300)
320
339
  details = ("details from the catalog" if catalog.returncode == 0
321
340
  else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
322
- print(f" [3/5] synced; connection is {connection}; {details}")
341
+ print(f" [3/5] {'synced to the llm CLI' if synced else 'the llm CLI not synced (the built-in client is in use)'};"
342
+ f" connection is {connection}; {details}")
323
343
 
324
344
  # An image generator: role image, which no chat shape admits, so it never
325
345
  # fills a seat or a collection; a chat probe and the suite would only
@@ -341,6 +361,16 @@ def main() -> None:
341
361
  " never a seat, not probed, no suite.\n")
342
362
  return
343
363
 
364
+ # A decision model's suggestion for its role, from what the catalog knows:
365
+ # kept for the model popup (metrics/decided.json), never set from here.
366
+ try:
367
+ hint = decide.suggest_role(alias, role_facts(alias, name, connection))
368
+ print(f" role suggested by {hint['by']}: {hint['role']} at {hint['confidence']:.2f}"
369
+ " (a suggestion; the role is yours to set in its model popup)" if "role" in hint
370
+ else f" no role suggestion: {hint['error']}")
371
+ except Exception as exc: # noqa: BLE001 -- a suggestion never stops an onboarding
372
+ print(f" no role suggestion: {type(exc).__name__}: {exc}")
373
+
344
374
  # 4. availability
345
375
  print(" [4/5] probing availability (4 calls)...")
346
376
  since = dt.datetime.now().isoformat(timespec="seconds")
@@ -414,6 +444,17 @@ def _selftest() -> None:
414
444
  # The name fallback, for a model the catalog says nothing about.
415
445
  assert all(IMAGE_NAMES.search(s) for s in ("gpt-image-2.5-flare", "openai/dall-e-3", "black-forest-labs/flux.2-pro"))
416
446
  assert not any(IMAGE_NAMES.search(s) for s in ("gpt-5.2", "reflux-7b", "gemini-3-pro"))
447
+ # What the role suggestion is told: the catalog's facts, the description last.
448
+ import catalog
449
+ real = catalog.info
450
+ catalog.info = lambda a: {"context_window": 131072, "vision": False, "input_modalities": ["text"],
451
+ "tools": None, "description": "A coding model."}
452
+ try:
453
+ facts = role_facts("m1", "vendor/m1", "groq").splitlines()
454
+ finally:
455
+ catalog.info = real
456
+ assert facts == ["model: vendor/m1", "connection: groq", "context window: 131072", "vision: False",
457
+ "input modalities: text", "description: A coding model."], facts
417
458
  print("ok")
418
459
 
419
460