tuieval 0.2.0.dev131__tar.gz → 0.2.0.dev133__tar.gz

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.
Files changed (82) hide show
  1. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/models.md +1 -0
  4. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/compare.py +6 -3
  6. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/engine.py +8 -0
  7. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/pta.py +28 -7
  8. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/tui.py +5 -2
  9. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/tests/test_tuieval.py +16 -8
  10. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/.gitignore +0 -0
  11. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/LICENSE +0 -0
  12. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/README.md +0 -0
  13. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/RELEASING.md +0 -0
  14. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/agent-packs.md +0 -0
  15. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/tui-answer-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/tui-run-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/images/tui-setup.png +0 -0
  33. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/docs/writing-packs.md +0 -0
  34. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/agents.py +0 -0
  38. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/builtin.toml +0 -0
  39. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/cli.py +0 -0
  40. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/client.py +0 -0
  41. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/dates.py +0 -0
  42. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/doctor.py +0 -0
  43. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/export.py +0 -0
  44. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/graders/__init__.py +0 -0
  45. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/graders/answer.py +0 -0
  46. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/graders/code.py +0 -0
  47. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/graders/rag.py +0 -0
  48. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/graders/reply.py +0 -0
  49. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/graders/tool_call.py +0 -0
  50. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/insights.py +0 -0
  51. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/logs.py +0 -0
  52. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/machines.py +0 -0
  53. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/onboard.py +0 -0
  54. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/packs.py +0 -0
  55. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/profiles.py +0 -0
  56. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/remove.py +0 -0
  57. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/run_evals.py +0 -0
  58. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev133}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Repeats: the PTA index, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
5
6
  - Partial credit: the PTA index shows each model's score (e.g. 5 of 6 checks passed is 83%) beside accuracy, and can be ordered by it (By score, `tuieval pta --sort score`). Why models fail splits failures into near miss, partly right and wrong; Failures shows each one's kind and checks and filters by kind; Per question cells show `✗ 5/6`. Verdicts are unchanged.
6
7
  - Results: picking a pack (PTA index or Per question) compares the models that answered all of it; the Models button can change that, and All packs goes back to the earlier pick.
7
8
  - Fix: a request that runs out of time says `time limit: timed out after N s`, not `connection error`, and `tuieval compare` counts it against the model like the run does (it was left out as an error).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev131
3
+ Version: 0.2.0.dev133
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
@@ -106,6 +106,7 @@ The **PTA index** puts parsimony (tokens), time and accuracy side by side, as a
106
106
  - **T time**: the total time to answer those questions, as how many times the fastest model's, e.g. `28×` (★ in **time/answer**).
107
107
  - **A accuracy**: the share of answers to those questions that passed. Answers cut off at `max_tokens` count as wrong, but show in orange after the green, with `(n% truncated)` beside the number and a **truncated** column in the table: those failures are a model out of tokens (overthinking), not a wrong answer yet. A pack's own `max_tokens` gives it more room.
108
108
  - **Score**: partial credit, averaged over the same answers. Graders that can tell how close an answer came give it: the code grader the share of checks passed (5 of 6 is 83%, where accuracy counts it wrong), agent packs the share of hidden tests, the tool-call and RAG graders part marks. It shows beside the accuracy when the two differ, and in a **score** column. Order the models by it with the **By score** dropdown in the tab or `tuieval pta --sort score`; verdicts still use accuracy only.
109
+ - **Repeats**: beside each name, how many times the model answered each question compared (`×3`, or `×1-3` when it varies), also a **repeats** column here and in `tuieval compare`. A `×1` model (in yellow) answered each question once, so its accuracy is rougher: one answer more or less right moves it by 100 / questions points. Setup shows the same beside each pack in a model's results (`✓coding×3`).
109
110
  - **Over budget**: some providers don't stop a model at `max_tokens` (thinking goes on past it), so the answer isn't cut off but the model had more room than the others. Answers that used more tokens than were sent as `max_tokens` show as `(n over budget)` beside the accuracy, an **over budget** column, `over` in Results → Per question and a note in the answer view. They're only flagged; `over_budget = "fail"` in `[defaults]` counts them as failed instead (stored results aren't changed, so switching back restores them).
110
111
 
111
112
  The P and T bars are as long as the tokens and time used: the model that used the most fills its bar, the others in proportion (at least one block), so **shorter is better**. The A bar is the share of answers right, so **longer is better**; it's full only when every answer passed. Each heading says which way is better.
@@ -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.dev131'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev131')
21
+ __version__ = version = '0.2.0.dev133'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev133')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -248,12 +248,14 @@ def scorecard(rows, rng=None):
248
248
  suites = sorted({r["suite"] for r in rows})
249
249
  extra = dict(graders.COLUMNS) # columns your graders add (graders.scorecard_column)
250
250
  over = any(r.get("over_budget") for r in rows) # a column only when some answer ran past max_tokens
251
- header = ["model"] + suites + ["overall", "95% CI", "mean score", "all repeats", *extra, "errors", "trunc",
251
+ header = ["model", "repeats"] + suites + ["overall", "95% CI", "mean score", "all repeats", *extra, "errors", "trunc",
252
252
  *(["over budget"] if over else []), "rep"]
253
253
  table = []
254
254
  for m in models:
255
255
  mine = [r for r in rows if r["model"] == m]
256
- line = [m]
256
+ per_test = collections.Counter(r["test"] for r in mine) # answers per question: the repeats
257
+ few, most = min(per_test.values()), max(per_test.values())
258
+ line = [m, f"×{few}" if few == most else f"×{few}-{most}"]
257
259
  for s in suites:
258
260
  sub = [r for r in mine if r["suite"] == s]
259
261
  line.append(f"{sum(r['ok'] for r in sub)}/{len(sub)}" if sub else "-")
@@ -678,7 +680,8 @@ def main(argv):
678
680
  for note in settings_notes(infos):
679
681
  print("NOTE:", note)
680
682
  print_table(*scorecard(rows, rng))
681
- print("\nmean score = average score, with partial credit; all repeats = tests passed on every repeat.")
683
+ print("\nrepeats = answers per question (×1: one answer each, the roughest accuracy).")
684
+ print("mean score = average score, with partial credit; all repeats = tests passed on every repeat.")
682
685
  print("trunc = hit max_tokens; rep = output ends in a degenerate repeated pattern.")
683
686
  header, table = by_difficulty(rows)
684
687
  if any(r.get("difficulty") in LEVELS for r in rows):
@@ -989,6 +989,14 @@ class Engine:
989
989
  need = {(t["id"], r) for t in pack.tests for r in range(self.certify_repeat(pack))}
990
990
  return "certified" if need <= keys else "screened"
991
991
 
992
+ def result_repeats(self, label, pack_name, tier="certify"):
993
+ """How many times each answered question of a stored result was asked: (fewest, most), or None."""
994
+ data = self._result(self.result_path(label, pack_name, tier))
995
+ if not data or not data["results"]:
996
+ return None
997
+ per_test = collections.Counter(r["test"] for r in data["results"])
998
+ return min(per_test.values()), max(per_test.values())
999
+
992
1000
  def _partial_lines(self, path):
993
1001
  """(header, answers) of an unfinished run's .partial.jsonl, cached like _result;
994
1002
  (None, []) if there is none."""
@@ -15,6 +15,9 @@ the others in proportion, at least one block), so shorter is better; the A bar:
15
15
  P and T also carry a 0-100 score (100 for the best, POINTS_PER_DOUBLING points less for every
16
16
  doubling), used to order models with the same accuracy.
17
17
 
18
+ Beside each name, the repeats: how many times it answered each of those questions ("×3", or "×1-3" when
19
+ it varies). A ×1 model's accuracy rests on one answer per question, so it is rougher than a ×3 one's.
20
+
18
21
  Models are compared on the questions all of them answered, so one that skipped some never looks
19
22
  faster. Server and connection errors aren't answers. By default a model with fewer than half as many
20
23
  answers as the most-answered model is left out (listed, so it can be picked), or it would shrink the
@@ -54,7 +57,7 @@ SORTS = ("accuracy", "score")
54
57
 
55
58
  def index(rows, models=None, memory=None, sort="accuracy"):
56
59
  """{"questions": n shared, "models": [{model, P, T, A, S, cut, cut_pct, tokens, total_s, passed, answers, alongside,
57
- fewest, fastest, tps, ttft, memory_gb}],
60
+ fewest, fastest, tps, ttft, memory_gb, repeats: (fewest, most) answers per question}],
58
61
  "left_out": [(model, questions answered)], "no_answers": [model], "most": questions answered by the
59
62
  most-answered model}, best accuracy (sort="accuracy") or score (sort="score") first. S: the mean
60
63
  score (partial credit) in %. models: the ones to compare (default: every model in rows
@@ -92,6 +95,7 @@ score (partial credit) in %. models: the ones to compare (default: every model i
92
95
  "tps": compare.median(r.get("gen_tps") for c in cells for r in c["rows"]),
93
96
  "ttft": compare.median(r.get("ttft") for c in cells for r in c["rows"]),
94
97
  "memory_gb": (memory or {}).get(m),
98
+ "repeats": (min(c["n"] for c in cells), max(c["n"] for c in cells)) if cells else None,
95
99
  # answers that ran while other models were served shared the machine's speed
96
100
  "alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
97
101
  fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
@@ -116,6 +120,14 @@ LETTERS = (("P", "parsimony (tokens)", "▼ shorter is better", "tokens", "token
116
120
  ("A", "accuracy", "▲ longer is better", "A", "A", "green"))
117
121
 
118
122
 
123
+ def repeats(x):
124
+ """How many times a model answered each question compared: "×3", "×1-3", or "" when unknown."""
125
+ r = x.get("repeats")
126
+ if not r:
127
+ return ""
128
+ return f"×{r[0]}" if r[0] == r[1] else f"×{r[0]}-{r[1]}"
129
+
130
+
119
131
  def times(v):
120
132
  """A multiple of the best: "1.0×", "2.4×", "28×"."""
121
133
  return "-" if v is None else f"{v:.1f}×" if v < 9.95 else f"{v:.0f}×"
@@ -138,6 +150,8 @@ def bars(result, width=20, markup=True, click=None):
138
150
  if not result["models"]:
139
151
  return []
140
152
  name_w = max(len(x["model"]) for x in result["models"])
153
+ rep_w = max(len(repeats(x)) for x in result["models"])
154
+ name_w += rep_w + 1 if rep_w else 0 # the repeats sit after the name, in its column
141
155
  # each column: the bar, a space, "100%" or "9.8×" (6), then a gap; at least as wide as its heading
142
156
  cell_w = max(width + 9, *(len(t) + 2 for k, name, better, *_ in LETTERS for t in (f"{k} {name}", better)))
143
157
  pad = " " * (name_w + 2)
@@ -173,11 +187,14 @@ def bars(result, width=20, markup=True, click=None):
173
187
  bar = (f"[{color}]{bar}[/]" + (f"[#ff8c00]{'█' * part}[/]" if part else "") + f"[grey37]{rest}[/]"
174
188
  if markup else bar + cut_bar + rest)
175
189
  cells.append(f"{bar} {num:>6}" + " " * max(0, cell_w - width - 1 - max(6, len(num))))
176
- name = f"{x['model']:<{name_w}}"
190
+ tag = repeats(x)
191
+ name = f"{x['model'] + (' ' + tag if tag else ''):<{name_w}}"
177
192
  if markup:
178
- pad_w = name_w - len(x["model"])
179
- name = (f"[@click={click}({row})]{rich_escape(x['model'])}[/]" + " " * pad_w if click
180
- else rich_escape(name))
193
+ pad_w = name_w - len(x["model"]) - (len(tag) + 1 if tag else 0)
194
+ # a single answer per question in yellow: its accuracy is the roughest
195
+ tag = f" [{'yellow' if x['repeats'][1] == 1 else 'dim'}]{tag}[/]" if tag else ""
196
+ name = ((f"[@click={click}({row})]{rich_escape(x['model'])}[/]" if click else rich_escape(x["model"]))
197
+ + tag + " " * pad_w)
181
198
  lines.append((f"{name} " + "".join(cells)).rstrip())
182
199
  return lines
183
200
 
@@ -202,9 +219,9 @@ def table(result):
202
219
  """(header, rows) for a plain table of the index."""
203
220
  n = result["questions"] or 1
204
221
  over = any(x.get("over") for x in result["models"]) # a column only when some answer ran past max_tokens
205
- header = ["model", "tokens/answer", "time/answer", "answers right", "score", "truncated", *(["over budget"] if over else []),
222
+ header = ["model", "repeats", "tokens/answer", "time/answer", "answers right", "score", "truncated", *(["over budget"] if over else []),
206
223
  "tok/s", "TTFT s", "memory GB"]
207
- rows = [[x["model"], f"{round(x['tokens'] / n):,}" + (" ★" if x.get("fewest") else ""),
224
+ rows = [[x["model"], repeats(x) or "-", f"{round(x['tokens'] / n):,}" + (" ★" if x.get("fewest") else ""),
208
225
  fmt_secs(x["total_s"] / n) + (" ★" if x.get("fastest") else ""),
209
226
  f"{x['passed']}/{x['answers']}", "-" if x.get("S") is None else compare.pct(x["S"] / 100),
210
227
  f"{x.get('cut') or 0}/{x['answers']}",
@@ -243,6 +260,10 @@ def scope_note(result, models):
243
260
  "P parsimony (tokens) and T time: bars as long as the tokens / time used (shorter is better), "
244
261
  "and how many times the leanest / fastest model's (1.0×, ★ in the table). A accuracy: answers right "
245
262
  "(longer is better); score: partial credit, e.g. 5 of 6 checks passed is 83% (shown when it differs)"
263
+ + (f"; ×n beside a name: answers per question. ×1 models answered each question once, so one "
264
+ f"answer more or less right moves their accuracy by {100 / result['questions']:.0f} points; "
265
+ "repeats steady it" if len({x.get("repeats") for x in result["models"]}) > 1
266
+ or any(x.get("repeats") and x["repeats"][1] == 1 for x in result["models"]) else "")
246
267
  + ("; orange, (n% truncated): answers cut off at max_tokens. They count as wrong, but the model ran "
247
268
  "out of tokens rather than answering wrong." if any(x.get("cut") for x in result["models"]) else ".")
248
269
  + (" Over budget: answers that used more tokens than the max_tokens sent, because the provider didn't "
@@ -937,7 +937,10 @@ class SetupScreen(Screen):
937
937
  for n in e.packs:
938
938
  st = e.result_status(label, n)
939
939
  if st:
940
- packs.append(f"{mark.get(st, '[yellow]~[/yellow]')}{n}")
940
+ reps = e.result_repeats(label, n) if st in ("certified", "screened") else None
941
+ times = ("" if not reps else f"[dim]×{reps[0]}[/dim]" if reps[0] == reps[1]
942
+ else f"[dim]×{reps[0]}-{reps[1]}[/dim]")
943
+ packs.append(f"{mark.get(st, '[yellow]~[/yellow]')}{n}{times}")
941
944
  if st.startswith("outdated"):
942
945
  why.setdefault(st.removeprefix("outdated: "), []).append(n)
943
946
  if why: # each reason once, not once per pack
@@ -951,7 +954,7 @@ class SetupScreen(Screen):
951
954
  hidden = (f" [cyan]{' '.join(tags)}[/cyan]" if tags else "") + hidden
952
955
  detail.update(f"[b]{label}[/b] [dim]{m['server']} · {m.get('model') or ''}[/dim]"
953
956
  f"{serving_badge(e, m, not e.has_results())}{hidden}\n{verdicts}\n"
954
- + ("[dim]results (✓ certified ◐ screened ~ outdated … partial):[/dim] " + " ".join(packs)
957
+ + ("[dim]results (✓ certified ◐ screened ~ outdated … partial; ×n answers per question):[/dim] " + " ".join(packs)
955
958
  if packs else "[dim]no results yet[/dim]"))
956
959
 
957
960
  @on(Checkbox.Changed, "#show-hidden")
@@ -502,6 +502,9 @@ class PickTests(unittest.TestCase):
502
502
  self.assertIn("done earlier", out) # nothing missing: not re-asked
503
503
  tuieval(self.ws, "run", "--tests", "apps:d", "--tier", "certify")
504
504
  self.assertEqual(self.status(), "certified")
505
+ code = "from tuieval import engine; print(engine.Engine().result_repeats('m', 'apps'))"
506
+ self.assertEqual(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, # ✓×1 on Setup
507
+ env=dict(os.environ, TUIEVAL_HOME=self.ws)).stdout.strip(), "(1, 1)")
505
508
 
506
509
  self.server("wrong")
507
510
  tuieval(self.ws, "run", "--tests", "apps:b", "--force")
@@ -1555,7 +1558,7 @@ class PTAIndex(unittest.TestCase):
1555
1558
  scale, and accuracy, all over the questions every compared model answered."""
1556
1559
 
1557
1560
  def test_index_compares_shared_questions(self):
1558
- from tuieval import pta
1561
+ from tuieval import compare, pta
1559
1562
 
1560
1563
  def row(model, test, ok, secs, repeat=0):
1561
1564
  return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "latency": secs * 1000,
@@ -1574,9 +1577,9 @@ class PTAIndex(unittest.TestCase):
1574
1577
  self.assertEqual((by["slow"]["P"], by["fast"]["P"]), (100, 70)) # 4x the tokens: two doublings
1575
1578
  self.assertEqual((pta.score(2, 1), pta.score(1000, 1)), (85, 0))
1576
1579
  header, table = pta.table(res)
1577
- self.assertEqual(header[1:4], ["tokens/answer", "time/answer", "answers right"])
1580
+ self.assertEqual(header[1:5], ["repeats", "tokens/answer", "time/answer", "answers right"])
1578
1581
  cells = {r[0]: r for r in table}
1579
- self.assertEqual((cells["slow"][1], cells["fast"][1:3]), ("10 ★", ["40", "2.0s ★"])) # per answer
1582
+ self.assertEqual((cells["slow"][2], cells["fast"][2:4]), ("10 ★", ["40", "2.0s ★"])) # per answer
1580
1583
  bars = pta.bars(res, 10, markup=False)
1581
1584
  self.assertEqual(bars[0].split(), ["P", "parsimony", "(tokens)", "T", "time", "A", "accuracy"])
1582
1585
  self.assertEqual(bars[1].split(), ["▼", "shorter", "is", "better"] * 2 + ["▲", "longer", "is", "better"])
@@ -1585,8 +1588,13 @@ class PTAIndex(unittest.TestCase):
1585
1588
  (fast.index("██████████"), fast.index("███░░░░░░░"))) # its heading
1586
1589
  # P and T bars as long as tokens and time (shorter is better): fast used 4x the tokens of slow but
1587
1590
  # took 4 s against 13 s, so it has the longest token bar and the shortest time bar
1588
- self.assertEqual(fast.split(), ["fast", "██████████", "4.0×", "███░░░░░░░", "1.0×", "█████░░░░░", "50.0%"])
1589
- self.assertEqual(slow.split()[1:5], ["██░░░░░░░░", "1.0×", "██████████", "3.2×"])
1591
+ self.assertEqual(fast.split(), ["fast", "×1", "██████████", "4.0×", "███░░░░░░░", "1.0×", "█████░░░░░", "50.0%"])
1592
+ self.assertEqual(slow.split()[1:6], ["×1-2", "██░░░░░░░░", "1.0×", "██████████", "3.2×"]) # ×: answers per question
1593
+ self.assertEqual((cells["slow"][1], cells["fast"][1]), ("×1-2", "×1"))
1594
+ self.assertIn("×1 models answered each question once", pta.scope_note(res, ["slow", "fast"]))
1595
+ full = [dict(r, score=float(r["ok"]), error=False, output="") for r in rows]
1596
+ header, table = compare.scorecard(full)
1597
+ self.assertEqual({r[0]: r[header.index("repeats")] for r in table}, {"slow": "×1-2", "fast": "×1"})
1590
1598
  self.assertEqual((pta.times(1), pta.times(9.84), pta.times(27.9)), ("1.0×", "9.8×", "28×"))
1591
1599
  self.assertEqual((by["fast"]["A"], by["slow"]["A"]), (50, 100))
1592
1600
  self.assertEqual([x["model"] for x in res["models"]], ["slow", "fast"]) # best accuracy first
@@ -1618,8 +1626,8 @@ class PTAIndex(unittest.TestCase):
1618
1626
  self.assertEqual(bars["plain"][-2:], ["██████░░░░", "60.0%"]) # none truncated: nothing beside it
1619
1627
  self.assertIn("[#ff8c00]██[/]", pta.bars(res, 10)[2] + pta.bars(res, 10)[3])
1620
1628
  header, table = pta.table(res)
1621
- self.assertEqual(header[4:6], ["score", "truncated"])
1622
- self.assertEqual({r[0]: r[5] for r in table}, {"thinker": "2/10", "plain": "0/10"})
1629
+ self.assertEqual(header[5:7], ["score", "truncated"])
1630
+ self.assertEqual({r[0]: r[6] for r in table}, {"thinker": "2/10", "plain": "0/10"})
1623
1631
  self.assertIn("truncated", pta.scope_note(res, ["thinker", "plain"]))
1624
1632
  self.assertNotIn("truncated", pta.scope_note(pta.index(rows[10:]), ["plain"]))
1625
1633
 
@@ -1949,7 +1957,7 @@ class PTAIndex(unittest.TestCase):
1949
1957
  self.assertGreaterEqual(len(got["opened"]), 2)
1950
1958
  self.assertEqual(got["opened"], {m: m for m in got["opened"]}) # a cell opens its column's model
1951
1959
  self.assertEqual(got["tab"], "tab-pta") # Results opens on the PTA index
1952
- self.assertEqual([(r[0], r[3]) for r in got["rows"]][0], ("good", "4/4"))
1960
+ self.assertEqual([(r[0], r[4]) for r in got["rows"]][0], ("good", "4/4"))
1953
1961
  s = got["sorts"] # a click on a column name sorts by it, a second click reverses
1954
1962
  self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
1955
1963
  self.assertIn("answers right ▼", s["labels"])
File without changes
File without changes