tuieval 0.2.0.dev90__py3-none-any.whl → 0.2.0.dev94__py3-none-any.whl

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.
tuieval/_version.py CHANGED
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0.dev90'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev90')
21
+ __version__ = version = '0.2.0.dev94'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev94')
23
23
 
24
24
  __commit_id__ = commit_id = None
tuieval/builtin.toml CHANGED
@@ -5,6 +5,7 @@
5
5
  [defaults]
6
6
  repeat = 3
7
7
  request_timeout_ms = 1800000 # per request; long thinking runs need it
8
+ stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
8
9
  ready_timeout_s = 1200 # how long a model may take to load before giving up
9
10
  model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
10
11
 
tuieval/cli.py CHANGED
@@ -15,6 +15,7 @@ Start here
15
15
  Day to day
16
16
  tuieval compare [...] scorecards (--speed, --pairwise, --reference M, --failures)
17
17
  tuieval pta the PTA index: parsimony (tokens), time and accuracy per model, as bars
18
+ tuieval insights charts with what to do next: vs a reference, per use case, why models fail, …
18
19
  tuieval report the verdicts with their evidence, as a markdown file
19
20
  tuieval history every verdict change, newest first
20
21
  tuieval items tests that don't separate models or look broken
tuieval/client.py CHANGED
@@ -99,10 +99,17 @@ def count_tokens(base_url, text):
99
99
  return None
100
100
 
101
101
 
102
- def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, headers=None):
102
+ def _stalled(stall_s):
103
+ return f"server error: stalled, no tokens for {stall_s:.0f} s after it started answering"
104
+
105
+
106
+ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, headers=None, stall_s=None):
103
107
  """POST {base_url}/v1/chat/completions with streaming. Returns a dict (see module docstring).
104
108
 
105
109
  on_delta(kind, text) is called for "reasoning" and "answer" text as it arrives.
110
+ timeout bounds the whole request, keep-alive comments (OpenRouter's ": OPENROUTER PROCESSING")
111
+ included; running out counts against the model, like any slow answer. stall_s: once tokens have
112
+ started, this long without one is the server stalling (a server error, never the model's answer).
106
113
  """
107
114
  stream = stream or Stream()
108
115
  body = dict(body, stream=True, stream_options={"include_usage": True})
@@ -111,7 +118,7 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
111
118
  out = {"answer": "", "reasoning": "", "tool_calls": [], "finish": None, "usage": {}, "timings": {},
112
119
  "ttft_s": None, "total_s": None, "error": None}
113
120
  reasoning, answer, tools = [], [], {}
114
- start = time.time()
121
+ start = last = time.time()
115
122
  try:
116
123
  conn.request("POST", prefix + "/v1/chat/completions", body=json.dumps(body),
117
124
  headers={"Content-Type": "application/json", "Authorization": "Bearer none", **(headers or {})})
@@ -123,6 +130,13 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
123
130
  out["error"] = f"server returned HTTP {r.status}: {r.read()[:300].decode('utf-8', 'replace')}"
124
131
  return out
125
132
  for raw in r:
133
+ now = time.time()
134
+ if now - start > timeout:
135
+ out["error"] = f"connection error: timed out after {timeout:.0f} s in all"
136
+ break
137
+ if stall_s and out["ttft_s"] is not None and now - last > stall_s:
138
+ out["error"] = _stalled(stall_s)
139
+ break
126
140
  line = raw.decode("utf-8", "replace").strip()
127
141
  if not line.startswith("data:"):
128
142
  continue
@@ -147,8 +161,12 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
147
161
  fn = tc.get("function") or {}
148
162
  slot["name"] += fn.get("name") or ""
149
163
  slot["arguments"] += fn.get("arguments") or ""
150
- if (think or text or delta.get("tool_calls")) and out["ttft_s"] is None:
151
- out["ttft_s"] = time.time() - start
164
+ if think or text or delta.get("tool_calls"):
165
+ last = time.time()
166
+ if out["ttft_s"] is None:
167
+ out["ttft_s"] = last - start
168
+ if stall_s: # a server that goes silent mid-answer: don't wait out the whole timeout
169
+ stream.sock.settimeout(min(stall_s, timeout))
152
170
  if think:
153
171
  reasoning.append(think)
154
172
  on_delta and on_delta("reasoning", think)
@@ -158,7 +176,8 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
158
176
  except (OSError, http.client.HTTPException, ValueError) as e:
159
177
  if stream.cancelled:
160
178
  raise Cancelled() from None
161
- out["error"] = f"connection error: {e!r}"
179
+ stalled = stall_s and out["ttft_s"] is not None and isinstance(e, TimeoutError)
180
+ out["error"] = _stalled(stall_s) if stalled else f"connection error: {e!r}"
162
181
  finally:
163
182
  conn.close()
164
183
  out["total_s"] = time.time() - start
tuieval/engine.py CHANGED
@@ -2229,6 +2229,7 @@ class Engine:
2229
2229
  self.emit("job_started", job=job, resumed=len(done_keys),
2230
2230
  earlier_s=sum(s.get("wall_s") or 0 for s in job.earlier), sittings=len(job.earlier))
2231
2231
  timeout = self.cfg["defaults"]["request_timeout_ms"] / 1000
2232
+ stall_s = (self.cfg["defaults"].get("stall_timeout_ms") or 0) / 1000 or None
2232
2233
  headers = self.request_headers(job.model)
2233
2234
  # a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
2234
2235
  tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
@@ -2253,7 +2254,8 @@ class Engine:
2253
2254
  body = self._body(job, test, rep)
2254
2255
  res = client.stream_chat(base_url, body,
2255
2256
  lambda kind, text: self.emit("delta", stream=kind, text=text, label=job.label),
2256
- timeout=timeout, stream=self._stream, headers=headers)
2257
+ timeout=timeout, stream=self._stream, headers=headers,
2258
+ stall_s=stall_s)
2257
2259
  except client.Cancelled:
2258
2260
  self._check()
2259
2261
  raise Cancelled()
tuieval/insights.py ADDED
@@ -0,0 +1,538 @@
1
+ """Insights: what the results say to do next, as a chart per question with plain action lines under it
2
+ (Results → Insights, `tuieval insights`).
3
+
4
+ 1 vs reference each model against one reference model, paired on the questions both answered:
5
+ accuracy points with a 95% CI, better / tie / worse
6
+ 2 use cases accuracy per model and use case (pack group) as a heatmap; the best marked
7
+ 3 vs base a fine-tune against its base model (models.toml `base`): per pack, and the question
8
+ categories it gained or lost most
9
+ 4 why they fail each model's answers split into right, wrong, cut off, no answer / bad format, code
10
+ crashed or hung, and critical failures
11
+ 5 accuracy / cost accuracy against median tokens (or seconds) per question; the models no other beats
12
+ on both
13
+ 6 questions questions every model fails (check them), every model passes (no signal), and the
14
+ ones that split models most
15
+ 7 next runs the Certify runs that would settle the open verdicts of the strongest models, with
16
+ their time and command
17
+
18
+ Every panel returns {"title", "lines", "actions"}: lines are Rich markup (the chart), actions plain
19
+ sentences. Server and connection errors are never answers here either.
20
+ """
21
+ import collections
22
+ import math
23
+ import statistics
24
+
25
+ from rich.markup import escape as rich_escape
26
+
27
+ from . import compare
28
+
29
+ MIN_SHARE = 0.5 # like the PTA index: models with fewer answers than this share of the most-answered are left out
30
+ HALF = 15 # half width of a diverging bar
31
+ COVERED = 0.8 # a use case counts as covered when a model answered this share of its questions
32
+ MAX_ACTIONS = 6
33
+
34
+
35
+ def _esc(s):
36
+ return rich_escape(str(s))
37
+
38
+
39
+ def prepare(rows, picked=None, ref=None):
40
+ """(rows without errors, the models to show): picked ones, else every model with enough answers;
41
+ the reference is always shown when it has answers."""
42
+ rows = [r for r in rows if not r.get("error")]
43
+ counts = collections.Counter(r["model"] for r in rows)
44
+ if picked:
45
+ models = sorted(m for m in picked if m in counts)
46
+ else:
47
+ most = max(counts.values(), default=0)
48
+ models = sorted(m for m, n in counts.items() if n >= MIN_SHARE * most)
49
+ if ref in counts and ref not in models:
50
+ models.append(ref)
51
+ return [r for r in rows if r["model"] in models], models
52
+
53
+
54
+ def default_reference(rows, models, configured=None):
55
+ """models.toml [defaults] reference when it has answers, else the most accurate model shown."""
56
+ if configured in models:
57
+ return configured
58
+ acc = {m: statistics.fmean(r["ok"] for r in rows if r["model"] == m) for m in models
59
+ if any(r["model"] == m for r in rows)}
60
+ return max(acc, key=lambda m: (acc[m], m), default=None)
61
+
62
+
63
+ def _pts(x):
64
+ return f"{100 * x:+.1f}"
65
+
66
+
67
+ def diverging(diff, lo, hi, scale, color):
68
+ """A bar from the centre line to diff, the CI around it as a thin line: -scale .. +scale."""
69
+ out = []
70
+ for i in range(2 * HALF + 1):
71
+ v = (i - HALF) / HALF * scale
72
+ step = scale / HALF / 2
73
+ filled = (0 < v <= diff + step) if diff > 0 else (diff - step <= v < 0) if diff < 0 else False
74
+ if i == HALF:
75
+ out.append(f"[{color}]█[/]" if filled else "│")
76
+ elif filled:
77
+ out.append(f"[{color}]█[/]")
78
+ elif lo - step <= v <= hi + step:
79
+ out.append("[#808080]─[/]")
80
+ else:
81
+ out.append(" ")
82
+ return "".join(out)
83
+
84
+
85
+ VERDICT_COLOR = {"better": "green", "worse": "red", "tie": "yellow"}
86
+
87
+
88
+ def _vs_lines(results, name_of=lambda x: x["model"]):
89
+ if not results:
90
+ return []
91
+ scale = max(0.05, *(max(abs(x["lo"]), abs(x["hi"]), abs(x["diff"])) for x in results))
92
+ w = max(len(name_of(x)) for x in results)
93
+ lines = [" " * (w + 2) + f"[dim]{_pts(-scale)} pts" + " " * (2 * HALF - 16) + f"{_pts(scale)} pts[/dim]"]
94
+ for x in results:
95
+ color = VERDICT_COLOR[x["verdict"]]
96
+ lines.append(f"{_esc(name_of(x)):<{w}} {diverging(x['diff'], x['lo'], x['hi'], scale, color)} "
97
+ f"[{color}]{_pts(x['diff'])} pts[/] [dim]({_pts(x['lo'])} to {_pts(x['hi'])}, "
98
+ f"{x['n']} q)[/dim] [{color}]{x['verdict'].upper()}[/]")
99
+ return lines
100
+
101
+
102
+ # ---------------------------------------------------------------- 1 vs reference
103
+ def vs_reference(rows, models, ref):
104
+ title = "Who's worth keeping: accuracy against the reference"
105
+ if not ref:
106
+ return {"title": title, "lines": ["[dim]No reference model with answers.[/dim]"], "actions": []}
107
+ res = sorted((x for x in compare.vs_reference(rows, ref) if x["pack"] == "all" and x["model"] in models),
108
+ key=lambda x: -x["diff"])
109
+ acc = statistics.fmean(r["ok"] for r in rows if r["model"] == ref) if any(r["model"] == ref for r in rows) else 0
110
+ lines = [f"[dim]Reference: [b]{_esc(ref)}[/b] ({compare.pct(acc)} right). Paired on the questions both "
111
+ "answered; the bar is the difference in points, the thin line its 95% CI.[/dim]"] + _vs_lines(res)
112
+ actions = []
113
+ for x in res:
114
+ if x["verdict"] == "better":
115
+ actions.append(f"{x['model']} beats {ref} by {100 * x['diff']:.1f} pts on {x['n']} shared questions.")
116
+ worse = [x for x in res if x["verdict"] == "worse"]
117
+ if worse:
118
+ actions.append(f"Behind {ref} for certain: " + ", ".join(f"{x['model']} ({_pts(x['diff'])})" for x in worse[:5])
119
+ + (f" and {len(worse) - 5} more" if len(worse) > 5 else "") + ": candidates to drop.")
120
+ same = [x for x in res if x["verdict"] == "tie" and x["lo"] == x["hi"] == 0]
121
+ if same:
122
+ actions.append("Same result as " + ref + " on every shared question: " + ", ".join(x["model"] for x in same)
123
+ + " (harder questions would separate them).")
124
+ ties = [x for x in res if x["verdict"] == "tie" and x not in same]
125
+ if ties:
126
+ actions.append("Too close to call against " + ref + ": " + ", ".join(
127
+ f"{x['model']} ({_pts(x['diff'])}, CI {_pts(x['lo'])} to {_pts(x['hi'])})" for x in ties[:4])
128
+ + ": more questions or a Certify run would decide.")
129
+ return {"title": title, "lines": lines, "actions": actions}
130
+
131
+
132
+ # ---------------------------------------------------------------- 2 use cases
133
+ HEAT = ((0.9, "bold green"), (0.75, "bold yellow"), (0.5, "bold #ff8c00"), (0.0, "bold red"))
134
+ CELL = 12
135
+
136
+
137
+ def _heat(acc):
138
+ return next(style for floor, style in HEAT if acc >= floor)
139
+
140
+
141
+ def use_cases(rows, models, packs):
142
+ """packs: {name: Pack} (for groups and question counts)."""
143
+ title = "Best model per use case"
144
+ groups = []
145
+ for name, p in packs.items():
146
+ if p.group not in groups:
147
+ groups.append(p.group)
148
+ groups = [g for g in groups if any(packs.get(r["suite"]) and packs[r["suite"]].group == g for r in rows)]
149
+ if not groups:
150
+ return {"title": title, "lines": ["[dim]No results yet.[/dim]"], "actions": []}
151
+ size = {g: sum(len(p.tests) for p in packs.values() if p.group == g) for g in groups}
152
+ per = collections.defaultdict(lambda: collections.defaultdict(list)) # (model, group) -> test -> oks
153
+ for r in rows:
154
+ if r["suite"] in packs:
155
+ per[r["model"], packs[r["suite"]].group][r["test"]].append(r["ok"])
156
+ cell = {}
157
+ for m in models:
158
+ for g in groups:
159
+ tests = per.get((m, g))
160
+ if tests:
161
+ cell[m, g] = (statistics.fmean(statistics.fmean(v) for v in tests.values()), len(tests) / max(1, size[g]))
162
+ best = {}
163
+ for g in groups:
164
+ full = {m: a for (m, gg), (a, cov) in cell.items() if gg == g and cov >= COVERED}
165
+ if full:
166
+ top = max(full.values())
167
+ best[g] = (sorted((m for m, a in full.items() if a == top)), sorted(full.items(), key=lambda x: -x[1]))
168
+ w = max(len(m) for m in models)
169
+ short = {g: (g if len(g) <= CELL - 1 else f"{groups.index(g) + 1} {g[:CELL - 4]}…") for g in groups}
170
+ lines = [" " * (w + 2) + "".join(f"[b]{short[g]:<{CELL}}[/b]" for g in groups)]
171
+ for m in models:
172
+ cells = []
173
+ for g in groups:
174
+ if (m, g) not in cell:
175
+ cells.append(f"[#808080]{'-':^{CELL - 1}}[/] ")
176
+ continue
177
+ a, cov = cell[m, g]
178
+ star = "★" if g in best and m in best[g][0] else " "
179
+ text = f"██ {100 * a:>3.0f}%{'◔' if cov < COVERED else ' '}{star}"
180
+ cells.append(f"[{_heat(a)}]{text:<{CELL - 1}}[/] ")
181
+ lines.append(f"{_esc(m):<{w}} " + "".join(cells))
182
+ lines.append("[dim]★ best (among models that answered most of it) · ◔ answered under "
183
+ f"{100 * COVERED:.0f}% of its questions ·[/dim] " + " ".join(f"[{st}]██[/] [dim]{lbl}[/dim]" for (_, st), lbl in
184
+ zip(HEAT, ("90%+", "75%+", "50%+", "under 50%"))))
185
+ if any(short[g] != g for g in groups):
186
+ lines.append("[dim]" + " · ".join(f"{i + 1} {g}" for i, g in enumerate(groups) if short[g] != g) + "[/dim]")
187
+ actions = []
188
+ for g in groups:
189
+ if g not in best:
190
+ actions.append(f"{g}: no model has answered most of its questions yet.")
191
+ continue
192
+ tops, ranked = best[g]
193
+ top = ranked[0][1]
194
+ names = " or ".join(tops[:2]) + (f" (and {len(tops) - 2} more tied)" if len(tops) > 2 else "")
195
+ line = f"{g}: use {names} ({compare.pct(top)})"
196
+ rest = [x for x in ranked if x[0] not in tops]
197
+ if rest:
198
+ gap = top - rest[0][1]
199
+ line += f"; next {rest[0][0]} ({compare.pct(rest[0][1])}, {100 * gap:.0f} pts behind)"
200
+ if gap < 0.05:
201
+ line += ": not a clear winner"
202
+ if top < 0.5:
203
+ line += ". No model gets half of it right"
204
+ actions.append(line + ".")
205
+ return {"title": title, "lines": lines, "actions": actions}
206
+
207
+
208
+ # ---------------------------------------------------------------- 3 vs base
209
+ def vs_base(rows, models, bases, packs):
210
+ """bases: {model: its base model's label} (models.toml `base`)."""
211
+ title = "Fine-tunes against their base model"
212
+ pairs = [(m, bases[m]) for m in models if bases.get(m) and any(r["model"] == bases[m] for r in rows)]
213
+ if not pairs:
214
+ missing = [m for m in models if bases.get(m)]
215
+ why = (f"The base models of {', '.join(missing)} have no answers to compare with."
216
+ if missing else 'Add base = "<label of its base model>" to a fine-tune in models.toml to see what it '
217
+ "gained or lost against its base.")
218
+ return {"title": title, "lines": [f"[dim]{_esc(why)}[/dim]"], "actions": []}
219
+ lines, actions = [], []
220
+ label = lambda p: packs[p].label if p in packs else p
221
+ for m, base in pairs:
222
+ two = [r for r in rows if r["model"] in (m, base)]
223
+ res = [x for x in compare.vs_reference(two, base) if x["model"] == m]
224
+ overall = next((x for x in res if x["pack"] == "all"), None)
225
+ if not overall:
226
+ continue
227
+ color = VERDICT_COLOR[overall["verdict"]]
228
+ lines.append(f"[b]{_esc(m)}[/b] vs [b]{_esc(base)}[/b]: [{color}]{_pts(overall['diff'])} pts "
229
+ f"{overall['verdict'].upper()}[/] [dim]({overall['n']} shared questions)[/dim]")
230
+ lines += [" " + line for line in _vs_lines([x for x in res if x["pack"] != "all"], lambda x: label(x["pack"]))]
231
+ cats = categories(two, m, base)
232
+ lost = [c for c in cats if c[1] <= -0.34][:3]
233
+ won = [c for c in sorted(cats, key=lambda c: -c[1]) if c[1] >= 0.34][:3]
234
+ if lost:
235
+ lines.append(" [red]lost on:[/] " + " · ".join(f"{_esc(c)} {_pts(d)} pts ({n} q)" for c, d, n in lost))
236
+ if won:
237
+ lines.append(" [green]gained on:[/] " + " · ".join(f"{_esc(c)} {_pts(d)} pts ({n} q)" for c, d, n in won))
238
+ lines.append("")
239
+ worse = [x for x in res if x["pack"] != "all" and x["verdict"] == "worse"]
240
+ better = [x for x in res if x["pack"] != "all" and x["verdict"] == "better"]
241
+ if worse:
242
+ actions.append(f"{m} is worse than its base on " + ", ".join(f"{label(x['pack'])} ({_pts(x['diff'])})"
243
+ for x in worse)
244
+ + (f"; it lost most on {', '.join(c for c, _, _ in lost)}: gaps in its fine-tuning data"
245
+ if lost else "") + ".")
246
+ if better:
247
+ actions.append(f"{m} beats its base on " + ", ".join(f"{label(x['pack'])} ({_pts(x['diff'])})" for x in better)
248
+ + ".")
249
+ if not worse and not better:
250
+ actions.append(f"{m}: no clear difference from {base} on any pack yet"
251
+ + (f" (losses on {', '.join(c for c, _, _ in lost)} are worth a look)" if lost else "") + ".")
252
+ return {"title": title, "lines": lines[:-1] if lines and not lines[-1] else lines, "actions": actions}
253
+
254
+
255
+ def categories(rows, m, base, min_q=2):
256
+ """[(category, mean pass-rate difference m - base, questions)] over categories with at least min_q
257
+ questions both answered, worst first. A question's category is its `category`, else its pack."""
258
+ by = collections.defaultdict(lambda: collections.defaultdict(list))
259
+ cat = {}
260
+ for r in rows:
261
+ by[r["model"]][r["test"]].append(r["ok"])
262
+ cat[r["test"]] = (r.get("raw") or {}).get("category") or r["suite"]
263
+ per = collections.defaultdict(list)
264
+ for t in set(by[m]) & set(by[base]):
265
+ per[cat[t]].append(statistics.fmean(by[m][t]) - statistics.fmean(by[base][t]))
266
+ return sorted(((c, statistics.fmean(v), len(v)) for c, v in per.items() if len(v) >= min_q), key=lambda c: c[1])
267
+
268
+
269
+ # ---------------------------------------------------------------- 4 why they fail
270
+ KINDS = (("right", "green", "█"), ("wrong", "red", "█"), ("cut off", "yellow", "█"),
271
+ ("no answer / format", "magenta", "█"), ("code crashed or hung", "cyan", "█"), ("critical", "#ff5faf", "▓"))
272
+ FORMAT = ("No 'ANSWER:' line", "Empty answer", "No code", "no tool call")
273
+ CRASHED = ("Timed out after", "Harness crashed", "grader error")
274
+
275
+
276
+ def kind(r):
277
+ if r["ok"]:
278
+ return "right"
279
+ if r.get("severity") == "critical":
280
+ return "critical"
281
+ if r["truncated"] or r["reason"].startswith("TRUNCATED"):
282
+ return "cut off"
283
+ if r["reason"].startswith(FORMAT):
284
+ return "no answer / format"
285
+ if r["reason"].startswith(CRASHED):
286
+ return "code crashed or hung"
287
+ return "wrong"
288
+
289
+
290
+ def _split(counts, total, width):
291
+ """Blocks per kind, largest remainders first, so they add up to width."""
292
+ raw = {k: width * counts.get(k, 0) / total for k, *_ in KINDS}
293
+ out = {k: int(v) for k, v in raw.items()}
294
+ for k in sorted(raw, key=lambda k: -(raw[k] - out[k]))[:width - sum(out.values())]:
295
+ out[k] += 1
296
+ for k in out: # a kind that happened shows at least one block
297
+ if counts.get(k) and not out[k]:
298
+ big = max(out, key=out.get)
299
+ out[big], out[k] = out[big] - 1, 1
300
+ return out
301
+
302
+
303
+ def failure_mix(rows, models, width=36):
304
+ title = "Why models fail"
305
+ counts = {m: collections.Counter(kind(r) for r in rows if r["model"] == m) for m in models}
306
+ counts = {m: c for m, c in counts.items() if c}
307
+ if not counts:
308
+ return {"title": title, "lines": ["[dim]No answers yet.[/dim]"], "actions": []}
309
+ w = max(len(m) for m in counts)
310
+ lines = [" " * (w + 2) + " ".join(f"[{c}]{ch}[/] {k}" for k, c, ch in KINDS)]
311
+ actions = []
312
+ for m in sorted(counts, key=lambda m: -counts[m]["right"] / sum(counts[m].values())):
313
+ c = counts[m]
314
+ total = sum(c.values())
315
+ blocks = _split(c, total, width)
316
+ bar = "".join(f"[{color}]{ch * blocks[k]}[/]" for k, color, ch in KINDS)
317
+ fails = total - c["right"]
318
+ detail = ", ".join(f"{c[k]} {k}" for k, *_ in KINDS[1:] if c[k])
319
+ lines.append(f"{_esc(m):<{w}} {bar} {compare.pct(c['right'] / total)} right [dim]({total} answers"
320
+ + (f"; {detail}" if detail else "") + ")[/dim]")
321
+ if fails >= 3:
322
+ top, n = max(((k, c[k]) for k, *_ in KINDS[1:-1]), key=lambda x: x[1])
323
+ if n / fails >= 0.4 and top != "wrong":
324
+ actions.append(f"{m}: {n} of its {fails} failures are {top}: " + {
325
+ "cut off": "it thinks too long for max_tokens, rather than reasoning wrong.",
326
+ "no answer / format": "it doesn't follow the answer format (check its chat template and the "
327
+ "pack's instructions).",
328
+ "code crashed or hung": "its code crashes or never finishes."}[top])
329
+ crit = sorted(((m, c["critical"]) for m, c in counts.items() if c["critical"]), key=lambda x: -x[1])
330
+ if crit:
331
+ actions.insert(0, "Critical failures: " + ", ".join(f"{m} ({n})" for m, n in crit[:5])
332
+ + (f" and {len(crit) - 5} more" if len(crit) > 5 else "")
333
+ + ": not safe where those matter, whatever the accuracy (Failures lists them).")
334
+ return {"title": title, "lines": lines, "actions": actions}
335
+
336
+
337
+ # ---------------------------------------------------------------- 5 accuracy / cost
338
+ MARKS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
339
+
340
+
341
+ def cost_points(rows, models, by="tokens"):
342
+ """({model: (accuracy, median cost per question)}, questions, shared): over the questions every model
343
+ answered when there are at least 5 of them, else each model over its own."""
344
+ tests = compare.per_question(rows)
345
+ shared = [t for t in tests if all(m in t["cells"] for m in models)]
346
+ use = shared if len(shared) >= 5 else tests
347
+ out = {}
348
+ for m in models:
349
+ cells = [t["cells"][m] for t in use if m in t["cells"]]
350
+ answers = sum(c["n"] for c in cells)
351
+ cost = compare.median(c[by] for c in cells)
352
+ if answers and cost:
353
+ out[m] = (sum(c["passed"] for c in cells) / answers, cost)
354
+ return out, len(use), use is shared
355
+
356
+
357
+ def front(points):
358
+ """The models no other model beats on both accuracy and cost."""
359
+ return {m for m, (a, c) in points.items()
360
+ if not any(a2 >= a and c2 <= c and (a2 > a or c2 < c) for m2, (a2, c2) in points.items() if m2 != m)}
361
+
362
+
363
+ def accuracy_cost(rows, models, by="tokens", width=56, height=12, hosted=()):
364
+ """hosted: models served by an API elsewhere; with local ones too, the local trade-offs get a line."""
365
+ unit = "tokens" if by == "tokens" else "seconds"
366
+ title = f"Accuracy against {unit} per question"
367
+ pts, n, shared = cost_points(rows, models, "tokens" if by == "tokens" else "secs")
368
+ if len(pts) < 2:
369
+ return {"title": title, "lines": ["[dim]Needs two models with answers.[/dim]"], "actions": []}
370
+ best = front(pts)
371
+ order = sorted(pts, key=lambda m: (-pts[m][0], pts[m][1]))
372
+ mark = {m: MARKS[i % len(MARKS)] for i, m in enumerate(order)}
373
+ accs, costs = [a for a, _ in pts.values()], [c for _, c in pts.values()]
374
+ a_lo = math.floor(min(accs) * 10) / 10
375
+ a_lo = min(a_lo, 0.9)
376
+ c_lo, c_hi = math.log(min(costs)), math.log(max(costs))
377
+ grid = [[" "] * width for _ in range(height)]
378
+ for m, (a, c) in pts.items():
379
+ x = 0 if c_hi == c_lo else round((math.log(c) - c_lo) / (c_hi - c_lo) * (width - 1))
380
+ y = round((1 - (a - a_lo) / (1 - a_lo)) * (height - 1)) if a_lo < 1 else 0
381
+ free = next((xx for xx in (x, x + 1, x - 1) if 0 <= xx < width and grid[y][xx] == " "), None)
382
+ if free is None:
383
+ grid[y][x] = "[b]*[/b]"
384
+ else:
385
+ grid[y][free] = f"[bold green]{mark[m]}[/]" if m in best else mark[m]
386
+ fmt = (lambda v: f"{v:,.0f}") if by == "tokens" else (lambda v: f"{v:.0f}s" if v >= 10 else f"{v:.1f}s")
387
+ lines = []
388
+ for i, row in enumerate(grid):
389
+ label = "100%" if i == 0 else f"{100 * a_lo:.0f}%" if i == height - 1 else ""
390
+ lines.append(f"[dim]{label:>5} │[/dim]" + "".join(row))
391
+ lo_s, hi_s = fmt(min(costs)), fmt(max(costs))
392
+ lines.append("[dim] └" + "─" * width + "[/dim]")
393
+ lines.append(f"[dim] {lo_s}{' ' * max(1, width - len(lo_s) - len(hi_s))}{hi_s} median {unit} "
394
+ "per question (log scale) →[/dim]")
395
+ lines.append(f"[dim]Over {n} questions {'every model answered' if shared else 'each model answered (too few in common)'}. "
396
+ "[bold green]Green[/] = no other model is both more accurate and cheaper. * = several models.[/dim]")
397
+ for m in order:
398
+ a, c = pts[m]
399
+ tag = f"[bold green]{mark[m]}[/]" if m in best else mark[m]
400
+ lines.append(f" {tag} {_esc(m)} [dim]{compare.pct(a)} · {fmt(c)}{' · hosted' if m in hosted else ''}[/dim]")
401
+ beaten = collections.defaultdict(list) # front model -> the models it beats on both
402
+ for m in order:
403
+ if m in best:
404
+ continue
405
+ a, c = pts[m]
406
+ better = [(m2, c2) for m2, (a2, c2) in pts.items() if m2 in best and a2 >= a and c2 <= c]
407
+ if better:
408
+ m2, c2 = min(better, key=lambda x: x[1])
409
+ beaten[m2].append(f"{m} ({100 * c2 / c:.0f}% of its {unit}"
410
+ + (", same accuracy)" if pts[m2][0] == a else ")"))
411
+ actions = ["Best trade-offs: " + ", ".join(m for m in order if m in best) + "."]
412
+ local = {m: p for m, p in pts.items() if m not in hosted}
413
+ if hosted and len(local) >= 2 and len(local) < len(pts):
414
+ lf = front(local)
415
+ actions.append("Among local models: " + ", ".join(m for m in order if m in lf) + ".")
416
+ for m2, items in beaten.items():
417
+ actions.append(f"{m2} is at least as accurate with fewer {unit} than " + ", ".join(items[:4])
418
+ + (f" and {len(items) - 4} more" if len(items) > 4 else "") + ".")
419
+ return {"title": title, "lines": lines, "actions": actions}
420
+
421
+
422
+ # ---------------------------------------------------------------- 6 question health
423
+ def question_health(rows, models, min_models=3, width=40):
424
+ title = "Question health"
425
+ need = min(min_models, len(models))
426
+ tests = [t for t in compare.per_question(rows) if len(t["cells"]) >= max(2, need)]
427
+ if not tests:
428
+ return {"title": title, "lines": [f"[dim]Needs questions answered by at least {max(2, need)} models.[/dim]"],
429
+ "actions": []}
430
+ rate = lambda t: statistics.fmean(c["passed"] / c["n"] for c in t["cells"].values())
431
+ none = [t for t in tests if all(c["passed"] == 0 for c in t["cells"].values())]
432
+ every = [t for t in tests if all(c["passed"] == c["n"] for c in t["cells"].values())]
433
+ split = sorted((t for t in tests if t not in none and t not in every),
434
+ key=lambda t: (abs(rate(t) - 0.5), -len(t["cells"])))
435
+ n = len(tests)
436
+ blocks = _split({"wrong": len(none), "right": len(every), "cut off": len(split)}, n, width)
437
+ bar = f"[red]{'█' * blocks['wrong']}[/][yellow]{'█' * blocks['cut off']}[/][#808080]{'█' * blocks['right']}[/]"
438
+ lines = [f"{bar} [dim]{n} questions answered by {max(2, need)}+ models[/dim]",
439
+ f"[yellow]█[/] {len(split)} split models [red]█[/] {len(none)} every model fails "
440
+ f"[#808080]█[/] {len(every)} every model passes"]
441
+ desc = lambda t: (t["cells"][next(iter(t["cells"]))]["rows"][0].get("raw") or {}).get("description") or t["name"]
442
+ for name, items, color in (("Split models most", split, "yellow"), ("Every model fails", none, "red"),
443
+ ("Every model passes", every, "#808080")):
444
+ if items:
445
+ lines.append(f"[{color}]{name}:[/]")
446
+ for t in items[:5]:
447
+ lines.append(f" [dim]{_esc(t['suite'])}[/dim] {_esc(desc(t)[:70])}"
448
+ + (f" [dim]({100 * rate(t):.0f}% of answers right)[/dim]" if items is split else ""))
449
+ if len(items) > 5:
450
+ lines.append(f" [dim]… and {len(items) - 5} more[/dim]")
451
+ actions = []
452
+ if none:
453
+ actions.append(f"{len(none)} question{'s' * (len(none) > 1)} no model passes: check their expected answers "
454
+ "(or keep them as stretch goals).")
455
+ if every:
456
+ secs = statistics.median(c["secs"] for t in every for c in t["cells"].values() if c["secs"]) \
457
+ if any(c["secs"] for t in every for c in t["cells"].values()) else None
458
+ saved = f", saving ~{_dur(secs * len(every))} per model" if secs else ""
459
+ actions.append(f"{len(every)} question{'s' * (len(every) > 1)} every model passes: they don't separate models; "
460
+ f"move them to a regression pack{saved}.")
461
+ if split:
462
+ actions.append(f"{len(split)} question{'s' * (len(split) > 1)} split models: these decide rankings; "
463
+ "write more like the top ones.")
464
+ return {"title": title, "lines": lines, "actions": actions}
465
+
466
+
467
+ def _dur(secs):
468
+ secs = int(secs)
469
+ return f"{secs // 3600}h{secs % 3600 // 60:02d}m" if secs >= 3600 else f"{secs // 60}m" if secs >= 60 else f"{secs}s"
470
+
471
+
472
+ # ---------------------------------------------------------------- 7 next runs
473
+ def next_runs(engine, models, ranking, limit=4, skip=()):
474
+ """ranking: models best first (by the reference comparison, else accuracy). The Certify runs that
475
+ would settle the open verdicts of the strongest `limit` models, with their time."""
476
+ from . import verdict
477
+ title = "Next runs"
478
+ table = verdict.readiness(engine, models)
479
+ times = engine.answer_times()
480
+ med = {l: {p: statistics.median(ts) for p, ts in pp.items() if ts} for l, pp in times.items()}
481
+ lines, actions, total = [], [], 0
482
+ shown = 0
483
+ for m in ranking:
484
+ if m not in table or m in skip or shown >= limit:
485
+ continue
486
+ todo = []
487
+ for g, (v, packs) in table[m].items():
488
+ for name, pv in packs.items():
489
+ ev = pv.evidence
490
+ if pv.status != "INCONCLUSIVE" or not ev or ev.get("certified"):
491
+ continue
492
+ left = max(0, ev["tests_total"] * ev["want_repeat"] - ev["trials"])
493
+ spa = med.get(m, {}).get(name) or statistics.median(
494
+ [x[name] for x in med.values() if name in x] or [60.0])
495
+ todo.append((name, left, left * spa))
496
+ if not todo:
497
+ continue
498
+ shown += 1
499
+ answers, secs = sum(x[1] for x in todo), sum(x[2] for x in todo)
500
+ label = ", ".join(engine.packs[n].label if n in engine.packs else n for n, *_ in todo)
501
+ lines.append(f"[b]{shown}. Certify {_esc(m)}[/b] on {_esc(label)}: ~{answers} answers, ~{_dur(secs)}")
502
+ lines.append(f" [dim]tuieval run --tier certify --only {_esc(m)} --packs {','.join(n for n, *_ in todo)}[/dim]")
503
+ total += secs
504
+ if shown:
505
+ actions.append(f"Certifying {'these ' + str(shown) + ' contenders' if shown > 1 else 'it'} takes ~{_dur(total)} "
506
+ "in all; the rest are behind them.")
507
+ if not lines:
508
+ lines = ["[dim]Nothing open among the strongest models: their verdicts are settled or not run yet.[/dim]"]
509
+ return {"title": title, "lines": lines, "actions": actions}
510
+
511
+
512
+ # ---------------------------------------------------------------- all of them
513
+ def ranking(rows, models, ref):
514
+ """Models best first: by the difference to the reference (the reference first), else by accuracy."""
515
+ diff = {x["model"]: x["diff"] for x in compare.vs_reference(rows, ref) if x["pack"] == "all"} if ref else {}
516
+ acc = {m: statistics.fmean(r["ok"] for r in rows if r["model"] == m) for m in models
517
+ if any(r["model"] == m for r in rows)}
518
+ key = (lambda m: -(diff.get(m, 0) if m != ref else 0)) if diff else (lambda m: -acc.get(m, 0))
519
+ return sorted(acc, key=lambda m: (key(m), m))
520
+
521
+
522
+ def panels(rows, models, packs, ref=None, bases=None, by="tokens", engine=None):
523
+ """All seven (the last needs the engine: verdicts and answer times). Models are shown best first."""
524
+ order = ranking(rows, models, ref)
525
+ order += [m for m in models if m not in order]
526
+ hosted = {m["label"] for m in engine.cfg["models"] if engine.hosted(m)} if engine is not None else set()
527
+ out = [vs_reference(rows, order, ref), use_cases(rows, order, packs), vs_base(rows, order, bases or {}, packs),
528
+ failure_mix(rows, order), accuracy_cost(rows, order, by, hosted=hosted), question_health(rows, order)]
529
+ if engine is not None:
530
+ out.append(next_runs(engine, order, order, skip={ref}))
531
+ return out
532
+
533
+
534
+ def render(panel, number=None):
535
+ """A panel as Rich markup: title, chart, then its action lines."""
536
+ head = f"[b reverse] {f'{number} ' if number else ''}{panel['title']} [/]"
537
+ acts = [f"[bold cyan]→[/] {_esc(a)}" for a in panel["actions"][:MAX_ACTIONS]]
538
+ return "\n".join([head, ""] + panel["lines"] + ([""] + acts if acts else []))
tuieval/run_evals.py CHANGED
@@ -427,6 +427,42 @@ def cmd_pta(argv):
427
427
  out.print(line, markup=False)
428
428
 
429
429
 
430
+ def cmd_insights(argv):
431
+ p = argparse.ArgumentParser(prog="tuieval insights",
432
+ description="Charts with what to do next: every model against a reference, the best "
433
+ "model per use case, fine-tunes against their base, why models fail, "
434
+ "accuracy against cost, question health and the runs that would settle "
435
+ "open verdicts.")
436
+ p.add_argument("--only", help="comma-separated model labels (default: every model with enough answers)")
437
+ p.add_argument("--reference", help="the model to compare against (default: [defaults] reference, else the "
438
+ "most accurate)")
439
+ p.add_argument("--packs", help="comma-separated packs (default: all)")
440
+ p.add_argument("--cost", choices=("tokens", "time"), default="tokens", help="the cost axis of the accuracy chart")
441
+ p.add_argument("--models")
442
+ p.add_argument("--results-dir")
443
+ a = p.parse_args(argv)
444
+ from rich.console import Console
445
+ from . import compare, insights
446
+ e = engine.Engine(models_path=a.models, results_dir=a.results_dir)
447
+ paths = [x for x in compare.default_paths(e.results_dir) if not e.superseded(x)]
448
+ rows, _ = compare.load(paths) if paths else ([], [])
449
+ if a.packs:
450
+ rows = [r for r in rows if r["suite"] in {x.strip() for x in a.packs.split(",")}]
451
+ wanted = a.reference or e.cfg["defaults"].get("reference")
452
+ picked = {x.strip() for x in a.only.split(",")} if a.only else None
453
+ rows, models = insights.prepare(rows, picked, wanted)
454
+ if not models:
455
+ sys.exit("no finished results" + (" for those models or packs" if a.only or a.packs else ""))
456
+ if a.reference and a.reference not in models:
457
+ sys.exit(f"{a.reference} has no finished results")
458
+ ref = insights.default_reference(rows, models, wanted)
459
+ bases = {m["label"]: m["base"] for m in e.cfg["models"] if m.get("base")}
460
+ out = Console(highlight=False)
461
+ for i, panel in enumerate(insights.panels(rows, models, e.packs, ref, bases, a.cost, engine=e), 1):
462
+ out.print(insights.render(panel, i), soft_wrap=True)
463
+ out.print()
464
+
465
+
430
466
  def cmd_report(argv):
431
467
  p = argparse.ArgumentParser(prog="tuieval report", description="Write the readiness report as markdown.")
432
468
  p.add_argument("-o", "--output", help="default: reports/readiness-<date>.md")
@@ -708,7 +744,7 @@ def cmd_export(argv):
708
744
  COMMANDS = {"add": cmd_add, "list": cmd_list, "scan": cmd_scan, "regrade": cmd_regrade,
709
745
  "verdict": cmd_verdict, "report": cmd_report, "items": cmd_items, "selftest": cmd_selftest,
710
746
  "capture": cmd_capture, "history": cmd_history, "machines": cmd_machines, "tune": cmd_tune,
711
- "export": cmd_export, "remove": cmd_remove, "pta": cmd_pta}
747
+ "export": cmd_export, "remove": cmd_remove, "pta": cmd_pta, "insights": cmd_insights}
712
748
 
713
749
 
714
750
  def parse_tests(text, pack_names, known):
tuieval/tui.py CHANGED
@@ -2104,6 +2104,7 @@ class RunScreen(Screen):
2104
2104
  self.follow = True
2105
2105
  self.buf = {"reasoning": [], "answer": []}
2106
2106
  self.buf_lock = threading.Lock()
2107
+ self.req_live = {} # model label -> its request now: phase, ~tokens, started, last token
2107
2108
  self.started = time.time()
2108
2109
  self.req_started = None
2109
2110
  self.req_tokens = 0
@@ -2235,7 +2236,20 @@ class RunScreen(Screen):
2235
2236
  return # the app is shutting down; the UI thread may be waiting on the engine
2236
2237
  if kind in ("request_started", "model_loading") and self.watch not in self.active():
2237
2238
  self.watch = d["job"].label if "job" in d else d["label"] # follow the next model streaming
2239
+ if kind in ("request_started", "server_retry"): # here, not in handle(): deltas follow at once
2240
+ with self.buf_lock:
2241
+ self.req_live[d["job"].label if "job" in d else d["label"]] = {
2242
+ "phase": "waiting", "tokens": 0, "started": time.time(), "last": None}
2243
+ elif kind == "request_done":
2244
+ with self.buf_lock:
2245
+ self.req_live.pop(d["job"].label, None)
2238
2246
  if kind == "delta": # very frequent: buffer, flushed every 100 ms on the UI thread
2247
+ with self.buf_lock:
2248
+ lv = self.req_live.get(d.get("label"))
2249
+ if lv:
2250
+ lv["phase"] = "thinking" if d["stream"] == "reasoning" else "answering"
2251
+ lv["tokens"] += max(1, len(d["text"]) // 4)
2252
+ lv["last"] = time.time()
2239
2253
  if d.get("label", self.watch) != self.watch:
2240
2254
  return # another model running alongside: v switches to it
2241
2255
  with self.buf_lock:
@@ -2439,7 +2453,8 @@ class RunScreen(Screen):
2439
2453
  elapsed = (j.finished or time.time()) - j.started if j.started else 0
2440
2454
  elapsed += sum(s.get("wall_s") or 0 for s in j.earlier) # a resumed pack: its earlier sittings too
2441
2455
  # update_width: columns start at their header's width; "12" under ✓ would otherwise show as "1"
2442
- for col, value in (("status", f"[{style}]{j.status}[/{style}]"),
2456
+ status = f"[{style}]{j.status}[/{style}]" + (self.live_note(j.label) if j.status == "running" else "")
2457
+ for col, value in (("status", status),
2443
2458
  ("progress", f"{j.done}/{j.total}" if j.status != "skipped" or j.done else ""),
2444
2459
  ("pass", f"[green]{j.passed}[/green]" if j.passed else ""),
2445
2460
  ("fail", f"[red]{j.failed}[/red]" if j.failed else ""),
@@ -2447,6 +2462,21 @@ class RunScreen(Screen):
2447
2462
  ("note", short_note(j.note))):
2448
2463
  q.update_cell(j.key, col, value, update_width=True)
2449
2464
 
2465
+ def live_note(self, label):
2466
+ """What a running model's request is doing now, so a slow model doesn't look stuck: waiting for
2467
+ its first token, or thinking/answering with ~tokens so far, and how long it's been quiet."""
2468
+ with self.buf_lock:
2469
+ lv = dict(self.req_live.get(label) or {})
2470
+ if not lv:
2471
+ return ""
2472
+ now = time.time()
2473
+ if lv["last"] is None:
2474
+ return f" [dim]· waiting {fmt_secs(now - lv['started'])}[/dim]"
2475
+ note = f" [dim]· {lv['phase']} ~{lv['tokens']:,} tok[/dim]"
2476
+ quiet = now - lv["last"]
2477
+ return note + (f" [yellow]· quiet {fmt_secs(quiet)}[/yellow]" if quiet >= self.QUIET_S else "")
2478
+
2479
+ QUIET_S = 30 # a running model's row says how long it's been without a token after this long
2450
2480
  LIVE_ANSWERS = 5 # a pack's own times this session take over from its history after this many
2451
2481
 
2452
2482
  def eta_seconds(self):
@@ -2660,6 +2690,14 @@ class ResultsScreen(Screen):
2660
2690
  yield Static(id="pta-bars")
2661
2691
  yield DataTable(id="pta", zebra_stripes=True, cursor_type="none")
2662
2692
  yield Static(id="pta-left-out")
2693
+ with TabPane("Insights", id="tab-insights"):
2694
+ with Horizontal(id="ins-controls"):
2695
+ yield Select([], id="ins-ref", prompt="Reference model", allow_blank=True)
2696
+ yield Select([("Cost: tokens", "tokens"), ("Cost: time", "time")], id="ins-cost",
2697
+ allow_blank=False, value="tokens")
2698
+ yield Button("Models: all", id="ins-models")
2699
+ with VerticalScroll(id="ins-body"):
2700
+ yield Static(id="ins-panels")
2663
2701
  with TabPane("Production readiness"):
2664
2702
  yield Static(id="readiness-short")
2665
2703
  yield Static(self.READINESS_HELP, id="readiness-help")
@@ -2769,6 +2807,9 @@ class ResultsScreen(Screen):
2769
2807
  self.fill_per_question()
2770
2808
  self.fill_pta()
2771
2809
  self.fill_readiness(results_dir)
2810
+ self.insights_stale = True
2811
+ if self.query_one(TabbedContent).active == "tab-insights":
2812
+ self.fill_insights()
2772
2813
  self.sort_tables()
2773
2814
 
2774
2815
  @on(DataTable.RowSelected, "#failures")
@@ -2833,6 +2874,7 @@ class ResultsScreen(Screen):
2833
2874
 
2834
2875
  @on(Button.Pressed, "#pq-models")
2835
2876
  @on(Button.Pressed, "#pta-models")
2877
+ @on(Button.Pressed, "#ins-models")
2836
2878
  def pick_models(self):
2837
2879
  counts = {}
2838
2880
  for t in getattr(self, "pq_all_tests", []):
@@ -2851,8 +2893,55 @@ class ResultsScreen(Screen):
2851
2893
  # after the picker has closed: a table rebuilt under a modal keeps its header-only widths
2852
2894
  self.call_after_refresh(self.fill_per_question)
2853
2895
  self.call_after_refresh(self.fill_pta)
2896
+ self.insights_stale = True
2897
+ if self.query_one(TabbedContent).active == "tab-insights":
2898
+ self.call_after_refresh(self.fill_insights)
2854
2899
  self.app.push_screen(ModelPickScreen(models, counts, self.app.pq_models), picked)
2855
2900
 
2901
+ # -- insights: charts with what to do next (insights.py); built when the tab is first shown
2902
+ @on(TabbedContent.TabActivated)
2903
+ def tab_activated(self, event):
2904
+ if event.pane.id == "tab-insights" and getattr(self, "insights_stale", True):
2905
+ self.fill_insights()
2906
+
2907
+ @on(Select.Changed, "#ins-ref")
2908
+ @on(Select.Changed, "#ins-cost")
2909
+ def insights_changed(self, event):
2910
+ if event.select.id == "ins-ref" and event.value != Select.BLANK:
2911
+ self.app.insights_ref = event.value # kept for this session
2912
+ if getattr(self, "rows", None) is not None and not getattr(self, "filling_insights", False):
2913
+ self.fill_insights()
2914
+
2915
+ def fill_insights(self):
2916
+ from . import insights
2917
+ e = self.app.engine
2918
+ self.insights_stale = False
2919
+ rows = [r for r in self.rows if r.get("path") not in self.superseded]
2920
+ body = self.query_one("#ins-panels", Static)
2921
+ if not rows:
2922
+ body.update("No finished results yet.")
2923
+ return
2924
+ sel = self.query_one("#ins-ref", Select)
2925
+ every = sorted({r["model"] for r in rows if not r.get("error")})
2926
+ wanted = getattr(self.app, "insights_ref", None) or e.cfg["defaults"].get("reference")
2927
+ rows, models = insights.prepare(rows, self.app.pq_models, wanted if wanted in every else None)
2928
+ ref = insights.default_reference(rows, models, wanted)
2929
+ self.filling_insights = True
2930
+ try:
2931
+ with sel.prevent(Select.Changed):
2932
+ sel.set_options([(m, m) for m in every])
2933
+ if ref:
2934
+ sel.value = ref
2935
+ finally:
2936
+ self.filling_insights = False
2937
+ chosen = {m for m in self.app.pq_models if m in every}
2938
+ self.query_one("#ins-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
2939
+ main = os.path.abspath(self.results_dir or e.results_dir) == os.path.abspath(e.results_dir)
2940
+ bases = {m["label"]: m["base"] for m in e.cfg["models"] if m.get("base")}
2941
+ by = self.query_one("#ins-cost", Select).value or "tokens"
2942
+ out = insights.panels(rows, models, e.packs, ref, bases, by, engine=e if main else None)
2943
+ body.update("\n\n".join(insights.render(p, i) for i, p in enumerate(out, 1)))
2944
+
2856
2945
  @on(Select.Changed, "#pta-pack")
2857
2946
  def pta_changed(self):
2858
2947
  if getattr(self, "rows", None) is not None:
@@ -3176,6 +3265,10 @@ class EvalsApp(App):
3176
3265
  #toggle-smoke { width: auto; min-width: 32; }
3177
3266
  #pq-controls { height: 3; }
3178
3267
  #pta-controls { height: 3; }
3268
+ #ins-controls { height: 3; }
3269
+ #ins-controls Select { width: 40; margin-right: 1; }
3270
+ #ins-controls #ins-cost { width: 20; }
3271
+ #ins-body { height: 1fr; }
3179
3272
  #pta-controls Select { width: 28; margin-right: 1; }
3180
3273
  #pta-bars { height: auto; padding: 1 0 0 0; }
3181
3274
  #pta { width: 1fr; height: auto; margin-top: 1; }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev90
3
+ Version: 0.2.0.dev94
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -1,13 +1,14 @@
1
1
  tuieval/__init__.py,sha256=myVXVX7htbwezW6k3x_FuKgn1RSZ2WcTaDEMByGn6Wg,312
2
2
  tuieval/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
- tuieval/_version.py,sha256=2Oq3-jxURAJShYRsOSJLXcon3FKMZbru1-NNRC3Tw-M,535
4
- tuieval/builtin.toml,sha256=Kxw22RZCJhURUyu5Wz8tj5Y1nM85A8hFVRbA4ocoPUg,4293
5
- tuieval/cli.py,sha256=VwiaU1F9bZtU8C-gEowJq58kPeYQY4t6SY_HwrV-qgc,4702
6
- tuieval/client.py,sha256=kpsyLEZKfxCsvIGBHBbBy83TpI97CvnGCDq-4AbJS4Y,9986
3
+ tuieval/_version.py,sha256=fdKFKgBcDb2th9hbBPfqkbuerxM6n_GrUhycjixQLz0,535
4
+ tuieval/builtin.toml,sha256=Nwu7O0qFzcrDAxXO_VZtDPvjVwxZ29OtYOW0BPihZGs,4410
5
+ tuieval/cli.py,sha256=mr0o_ud6NctZeU4WIrh17x_uzEK0vTeCynGP0wrCeBY,4812
6
+ tuieval/client.py,sha256=wtACBVsngtWopWWhIc4bB6mEUEi31dvvDePN-PjmXbM,11122
7
7
  tuieval/compare.py,sha256=uXU_Yzf4zDSHoupHQrTmBhsfc8wCyI1nMaZQN81ybiA,30522
8
8
  tuieval/doctor.py,sha256=j2MdDORstsAGPnRmpsnxau1_fR6sTc_oEFGM5zugTJU,7246
9
- tuieval/engine.py,sha256=BS2BXLaxtw3RFI6-5PDvhV5VLh5v0m0cB_F3fPC5MtM,125105
9
+ tuieval/engine.py,sha256=96GWzKzMQ_KaG6wXrIuBiKWFCcblXWL9cQs89QYgj7Y,125256
10
10
  tuieval/export.py,sha256=dbHQgL_ubWtIPM1sLzAew_gETnrDJyd0zgqVA5vkpXM,9657
11
+ tuieval/insights.py,sha256=m6HZk1VbX-avHr3xyT8eVJ5OLjKa_EKTVBxsu04X_HQ,28947
11
12
  tuieval/logs.py,sha256=01gZK6tf53dIThaIrs1bEsz9eqKYVlcxpyaLnSPTVxo,8127
12
13
  tuieval/machines.py,sha256=t9tu6CZeXZON4EtOVnEwP4VtaynpVLf9Cz8TNjf7jtY,20124
13
14
  tuieval/onboard.py,sha256=IYETVDWCCo9vF0Dtj4_rr9VanYeCky8PwP3-2g5A_l0,6215
@@ -15,10 +16,10 @@ tuieval/packs.py,sha256=ZgaZ1m2ePYvDjmddSc3nafnMEn7kTq9WcDpCaJSZleQ,12026
15
16
  tuieval/profiles.py,sha256=soeLxjdB-l-OxSY8jZ4yjwfdSnsBV-QICcwKxRJS7nQ,3663
16
17
  tuieval/pta.py,sha256=i5oPj-Pz3N9u7bzzuZwsQvMGdxWdAncww2nR15K6PHY,12108
17
18
  tuieval/remove.py,sha256=Vwh43nm9lVb9U9aN-h6CMYbkTwdR3UWfZObwweF72n0,5157
18
- tuieval/run_evals.py,sha256=FU3bvu3jt9ZWg3YLsXNINbdeClXMioyc4zQ4yEhmBQs,47855
19
+ tuieval/run_evals.py,sha256=DN6FhC8Zj08N5pwlrCmlkbkTxA2vmT-G5sWJnp-S-AI,50114
19
20
  tuieval/scaffold.py,sha256=YBOqniJpf_GtKKAFF3_OHxxMGbmTWdFjcClGcOnrrKE,12130
20
21
  tuieval/selftest.py,sha256=nExkrOidgymlmvkxImEBfrQG5iTUN5WuRMIKtqusCCs,9835
21
- tuieval/tui.py,sha256=N0U-wsdBgftGlkZLcqQNG-IxG7YdVe-H_tIEf2YCrLA,176872
22
+ tuieval/tui.py,sha256=1JpAtIpq4zJAmiPyg5NYlQQm8lYuhoP1DiRM4-exHsw,181966
22
23
  tuieval/tune.py,sha256=azxxthgJeag8JyoQUgaRxq6oI1XvEgzb9vkszjJZH7w,39645
23
24
  tuieval/verdict.py,sha256=3SM8qrVdWNIFDENagaR5ZPBYEnjsBtkYB9WS5TDVssU,20517
24
25
  tuieval/watch_proxy.py,sha256=bnDO1i6n6yUs1hODoUWcMppJMGPavoQqs6aNYrIxpT8,11416
@@ -47,8 +48,8 @@ tuieval/templates/packs/tool_call/pack.toml,sha256=n4bx0lGJHSmW41uVqhfIXJ2BWZpmT
47
48
  tuieval/templates/packs/tool_call/system.txt,sha256=mKxu-lB-0YjLHXdgeFJa8x2C180OvcNz5cHv2N5Bg3U,254
48
49
  tuieval/templates/packs/tool_call/tests.yaml,sha256=Nv7c4syrzJ9cU8EBKSK_1ZSFx9V02aCjW4-2TBCPT7s,1859
49
50
  tuieval/templates/packs/tool_call/tools.yaml,sha256=HgxCkh0On2VwbHSsjAGM8tNCK7ZtKLGrs7lMCn3Fprs,1048
50
- tuieval-0.2.0.dev90.dist-info/METADATA,sha256=_M-Ldu1l5L2FlskdgZrJYrBWNnbHhkJKu4wiDZRvDrI,14580
51
- tuieval-0.2.0.dev90.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
- tuieval-0.2.0.dev90.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
53
- tuieval-0.2.0.dev90.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
54
- tuieval-0.2.0.dev90.dist-info/RECORD,,
51
+ tuieval-0.2.0.dev94.dist-info/METADATA,sha256=i9PkqtvzVrMcs37nNV4c6RpKMBnQ3skhzmQrqkuUtKU,14580
52
+ tuieval-0.2.0.dev94.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
53
+ tuieval-0.2.0.dev94.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
54
+ tuieval-0.2.0.dev94.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
55
+ tuieval-0.2.0.dev94.dist-info/RECORD,,