tuieval 0.2.0.dev135__tar.gz → 0.2.0.dev139__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.dev135 → tuieval-0.2.0.dev139}/CHANGELOG.md +2 -1
  2. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/models.md +1 -1
  4. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/engine.py +23 -3
  6. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/run_evals.py +9 -4
  7. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/tui.py +51 -19
  8. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/tests/test_tuieval.py +160 -6
  9. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/.gitignore +0 -0
  10. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/LICENSE +0 -0
  11. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/README.md +0 -0
  12. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/RELEASING.md +0 -0
  13. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/agent-packs.md +0 -0
  14. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/README.md +0 -0
  15. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/favicon.ico +0 -0
  16. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/readme-header.png +0 -0
  17. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/social-preview.png +0 -0
  18. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-1024.png +0 -0
  19. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-128.png +0 -0
  20. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-16.png +0 -0
  21. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-16.svg +0 -0
  22. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-256.png +0 -0
  23. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-32.png +0 -0
  24. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-48.png +0 -0
  25. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-512.png +0 -0
  26. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-64.png +0 -0
  27. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  28. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon.svg +0 -0
  29. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/tui-answer-reasoning.png +0 -0
  30. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/tui-run-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/images/tui-setup.png +0 -0
  32. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/docs/writing-packs.md +0 -0
  33. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/pyproject.toml +0 -0
  34. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/__init__.py +0 -0
  35. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/__main__.py +0 -0
  36. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/agents.py +0 -0
  37. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/builtin.toml +0 -0
  38. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/cli.py +0 -0
  39. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/client.py +0 -0
  40. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/compare.py +0 -0
  41. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/dates.py +0 -0
  42. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/doctor.py +0 -0
  43. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/export.py +0 -0
  44. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/graders/__init__.py +0 -0
  45. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/graders/answer.py +0 -0
  46. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/graders/code.py +0 -0
  47. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/graders/rag.py +0 -0
  48. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/graders/reply.py +0 -0
  49. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/graders/tool_call.py +0 -0
  50. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/insights.py +0 -0
  51. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/logs.py +0 -0
  52. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/machines.py +0 -0
  53. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/onboard.py +0 -0
  54. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/packs.py +0 -0
  55. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/profiles.py +0 -0
  56. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/pta.py +0 -0
  57. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev135 → tuieval-0.2.0.dev139}/tests/mock_server.py +0 -0
@@ -2,8 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Repeats (Setup, `tuieval run --repeat N`) add N rounds to the saved answers: ×1 run with 2 gives ×3, not ×2. Retest, a blank count and presets still mean the total.
5
6
  - Answers stuck in a loop (one short unit repeated over 2000 chars, in the reasoning or the answer) are stopped as they stream and failed as `LOOP`, instead of running to max_tokens. `[defaults] loop_stop_chars` sets the length; 0 turns it off.
6
- - 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.
7
+ - Repeats: the PTA index, Per question, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
7
8
  - 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.
8
9
  - 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.
9
10
  - 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.dev135
3
+ Version: 0.2.0.dev139
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,7 +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
+ - **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`). To steady it, set **Repeats** in Setup (or `tuieval run --repeat N`): N adds that many rounds to the answers already saved, so a `×1` model run with 2 ends up `×3` (with Retest, N in all; left blank, the tier's default count in all). A preset's repeat count is in all too, until you type another.
110
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).
111
111
 
112
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.dev135'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev135')
21
+ __version__ = version = '0.2.0.dev139'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev139')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -443,7 +443,7 @@ def save_preset(path, name, labels, packs, repeat, tests=None):
443
443
  class Job:
444
444
  model: dict
445
445
  pack: packs_mod.Pack
446
- repeat: int
446
+ repeat: int # rounds each question has once it's done (earlier ones included)
447
447
  tests: list # the tests this tier runs (or the ones picked)
448
448
  out_path: str
449
449
  sampling: dict
@@ -465,6 +465,7 @@ class Job:
465
465
  stop_early: bool = False # stop once the pack's FAIL is certain (verdict.fail_certain)
466
466
  first: list = dataclasses.field(default_factory=list) # test ids round 1 asks first (most failed)
467
467
  claimed: bool = False # its model's server took it on: it can no longer be removed from the run
468
+ base: int = 0 # rounds already in its result file that a chosen repeat count adds to
468
469
 
469
470
  @property
470
471
  def label(self):
@@ -1148,6 +1149,17 @@ class Engine:
1148
1149
  redo = {t["id"] for t in job.tests}
1149
1150
  return [r for r in rows if r["test"] not in redo]
1150
1151
 
1152
+ def saved_rounds(self, job):
1153
+ """The most rounds any of the job's questions has in its finished result file (0 when the file
1154
+ doesn't match the job's questions and settings). An unfinished run's answers don't count, so
1155
+ resuming one doesn't add more rounds."""
1156
+ data = self._result(job.out_path)
1157
+ if not data or data["pack"]["fingerprint"] != job.pack.fingerprint or not settings_match(data, job.settings):
1158
+ return 0
1159
+ ids = {t["id"] for t in job.tests}
1160
+ rounds = collections.Counter(t for t, _ in {(r["test"], r["repeat"]) for r in data["results"] if r["test"] in ids})
1161
+ return max(rounds.values(), default=0)
1162
+
1151
1163
  def _fill_progress(self, job):
1152
1164
  """Set a job's done/passed/failed and earlier sittings from what's stored."""
1153
1165
  kept, new, job.earlier = self._stored(job)
@@ -1212,10 +1224,13 @@ class Engine:
1212
1224
  return not current and header_matches(head, pack.fingerprint, settings)
1213
1225
 
1214
1226
  # ---- planning
1215
- def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None, stop_early=None):
1227
+ def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None, stop_early=None,
1228
+ total=False):
1216
1229
  """Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
1217
1230
  tests). repeat: times each question is asked; None = the tier default (1 for smoke and
1218
- screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
1231
+ screen, the pack's certification repeats for certify), counted in total; a number adds that
1232
+ many rounds to the ones already in the result file (a retest starts from none), or with total
1233
+ (a preset's count) is the rounds in all. tests: {pack: [test ids]} runs just
1219
1234
  those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
1220
1235
  matching results are kept and only missing answers are run, unless force; force with
1221
1236
  picked tests reruns those and keeps the pack's other answers. force is True, False, or the
@@ -1240,6 +1255,9 @@ class Engine:
1240
1255
  job.first = self.most_failed_first(pack)
1241
1256
  sv = self.serving(m)
1242
1257
  status = self.result_status(label, name, tier)
1258
+ if repeat and not total and job.merge and not job.redo and not (status or "").startswith("outdated"):
1259
+ job.base = self.saved_rounds(job)
1260
+ job.repeat = job.base + repeat
1243
1261
  job.discards = self._discards(job)
1244
1262
  missing = [n for n in pack.modules if not importlib.util.find_spec(n)]
1245
1263
  no_agent = agents.problem(self.cfg, pack, self.root) if pack.kind == "agent" else None
@@ -1273,6 +1291,8 @@ class Engine:
1273
1291
  job.note = f"{job.done} of {job.total} done earlier, running the rest"
1274
1292
  if job.status != "waiting":
1275
1293
  job.discards = []
1294
+ elif job.base:
1295
+ job.note = "; ".join(filter(None, [job.note, f"×{job.base} → ×{job.repeat}"]))
1276
1296
  for what, n, why in job.discards:
1277
1297
  if what == "unfinished": # (a finished file's reason is already the note)
1278
1298
  job.note = "; ".join(filter(None, [job.note, f"{n} unfinished answers set aside ({why})"]))
@@ -101,7 +101,8 @@ class Printer:
101
101
  "so the server may be returning cached answers rather than fresh ones", RED)
102
102
 
103
103
  def on_job_started(self, job, resumed, earlier_s=0, sittings=0):
104
- say(f"{job.key}: {job.count} tests x {job.repeat} repeats" + (f", resuming after {resumed}" if resumed else "")
104
+ reps = f"{job.repeat - job.base} more repeats (×{job.base} → ×{job.repeat})" if job.base else f"{job.repeat} repeats"
105
+ say(f"{job.key}: {job.count} tests x {reps}" + (f", resuming after {resumed}" if resumed else "")
105
106
  + (f" ({earlier_s / 60:.0f} min over {sittings} earlier sitting(s))" if sittings else ""))
106
107
 
107
108
  def on_request_started(self, job, test, test_id, has_image, repeat, difficulty="unrated"):
@@ -792,8 +793,9 @@ def main(argv=None):
792
793
  p.add_argument("--stop-early", action="store_true",
793
794
  help="stop each pack once its FAIL is certain (a critical failure, or a bar out of reach), "
794
795
  "asking first what models fail most (default: each pack's stop_early)")
795
- p.add_argument("--repeat", type=int, help="times each question is asked, any tier (default: 1 for smoke and "
796
- "screen, the pack's certification count for certify)")
796
+ p.add_argument("--repeat", type=int, help="rounds to add: each question is asked N more times on top of its "
797
+ "saved answers (with --retest, N in all). Default: up to 1 for "
798
+ "smoke and screen, the pack's certification count for certify")
797
799
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
798
800
  help="smoke: 3 tests; screen (default): a spread sample; certify: everything, with repeats")
799
801
  p.add_argument("--smoke", action="store_const", const="smoke", dest="tier", help="same as --tier smoke")
@@ -838,12 +840,14 @@ def main(argv=None):
838
840
  sys.exit("no models to run yet: tuieval add <GGUF path or model id>, or name a hosted one with "
839
841
  "--only openrouter:<model id>" if not models else
840
842
  "no local models; hosted ones (they cost money) run only when named: --only <label>")
843
+ repeat_total = False
841
844
  if args.preset:
842
845
  presets = engine.load_presets(os.path.join(os.path.dirname(os.path.abspath(models_file(args.models))),
843
846
  "presets.toml"))
844
847
  if args.preset not in presets:
845
848
  sys.exit(f"unknown preset {args.preset!r}; presets: {', '.join(presets) or 'none (save one in the TUI with p)'}")
846
849
  labels, pack_names, preset_repeat, tests = engine.resolve_preset(e.cfg, presets[args.preset], list(e.packs))
850
+ repeat_total = not args.repeat and bool(preset_repeat) # a preset's count is the rounds in all
847
851
  args.repeat = args.repeat or preset_repeat
848
852
  if args.packs:
849
853
  pack_names = [x.strip() for x in args.packs.split(",")]
@@ -853,7 +857,8 @@ def main(argv=None):
853
857
  if args.tests:
854
858
  tests, pack_names = parse_tests(args.tests, pack_names if (args.packs or args.preset) else None, e.packs)
855
859
  try:
856
- jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests, args.stop_early or None)
860
+ jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests, args.stop_early or None,
861
+ repeat_total)
857
862
  except packs_mod.PackError as err:
858
863
  sys.exit(str(err))
859
864
 
@@ -431,6 +431,15 @@ def trim_names(names, width):
431
431
  return trimmed if len(set(trimmed)) == len(set(names)) else list(names)
432
432
 
433
433
 
434
+ def repeats_tag(counts):
435
+ """A model's answers per question as markup, as the PTA index shows them: "×3", or "×1-3" when it varies;
436
+ yellow when it's ×1 (one answer decides each result, the roughest), else dim. "" for no answers."""
437
+ if not counts:
438
+ return ""
439
+ few, most = min(counts), max(counts)
440
+ return f"[{'yellow' if most == 1 else 'dim'}]×{few}{'' if few == most else f'-{most}'}[/]"
441
+
442
+
434
443
  def fit_first_column(header, rows, room, floor=24):
435
444
  """rows with the first cell trimmed in the middle (mid_trim) so a table of these columns fits room
436
445
  cells across, but never below floor cells. Cells are Rich markup; each column adds 2 cells of padding."""
@@ -1016,6 +1025,11 @@ class SetupScreen(Screen):
1016
1025
  list(self.query_one("#suites", SelectionList).selected), repeat,
1017
1026
  self.tier(), self.query_one("#force", Checkbox).value)
1018
1027
 
1028
+ def repeat_total(self):
1029
+ """Is the Repeats count the rounds in all? Yes while it's a loaded preset's; a typed one adds rounds."""
1030
+ value = self.query_one("#repeat", Input).value
1031
+ return bool(value) and value == getattr(self, "preset_repeat", None)
1032
+
1019
1033
  def parallel(self):
1020
1034
  """Models at a time for the next run: the field, or blank for this machine's parallel_models."""
1021
1035
  try:
@@ -1079,16 +1093,21 @@ class SetupScreen(Screen):
1079
1093
  "[b]s[/b] to start." + self.queue_hint())
1080
1094
  return
1081
1095
  picks = self.current_picks(suites)
1082
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
1096
+ total = self.repeat_total()
1097
+ jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks, total=total)
1083
1098
  rerun = not any(j.status == "waiting" for j in jobs)
1084
1099
  if rerun: # everything has results: Start runs it again, so estimate that
1085
- jobs = self.app.engine.plan(labels, suites, repeat, tier, True, picks)
1100
+ jobs = self.app.engine.plan(labels, suites, repeat, tier, True, picks, total=total)
1086
1101
  run = [j for j in jobs if j.status == "waiting"]
1087
1102
  skipped = [j for j in jobs if j.status == "skipped"]
1088
1103
  secs, notes = self.app.engine.estimate_seconds(jobs)
1089
- todo = sum(j.total for j in run)
1090
- reps = sorted({j.repeat for j in jobs})
1091
- reps_text = "/".join(map(str, reps)) + (" (pack default)" if repeat is None and len(reps) > 1 else "")
1104
+ todo = sum(j.total - j.done for j in run)
1105
+ reps = sorted({j.repeat - j.base for j in jobs})
1106
+ reps_text = "/".join(map(str, reps)) + (" (pack default)" if repeat is None and len(reps) > 1 else "") \
1107
+ + (" in all (preset)" if total else "")
1108
+ bases = sorted({j.base for j in run if j.base})
1109
+ if bases: # a chosen count adds to the rounds already answered
1110
+ reps_text += " more" + " [dim](" + ", ".join(f"×{b} → ×{b + repeat}" for b in bases) + ")[/dim]"
1092
1111
  text = (f"[b]{tier.title()}[/b]: [b]{len(labels)}[/b] model(s) × [b]{len(suites)}[/b] pack(s) × "
1093
1112
  f"[b]{reps_text}[/b] repeat(s) = "
1094
1113
  f"[b]{todo:,}[/b] answers · about [b]{fmt_secs(secs)}[/b]"
@@ -1149,6 +1168,7 @@ class SetupScreen(Screen):
1149
1168
  for name in sel.get("packs", []):
1150
1169
  if name in e.packs:
1151
1170
  packs.select(name)
1171
+ self.preset_repeat = str(sel["repeat"]) if sel.get("repeat") and sel.get("repeat_total") else None
1152
1172
  self.query_one("#repeat", Input).value = str(sel["repeat"]) if sel.get("repeat") else ""
1153
1173
  if "parallel" in sel:
1154
1174
  self.query_one("#parallel", Input).value = str(sel["parallel"]) if sel["parallel"] else ""
@@ -1192,10 +1212,11 @@ class SetupScreen(Screen):
1192
1212
  self.notify("Select at least one eval pack and one model.", severity="warning")
1193
1213
  return
1194
1214
  picks = self.current_picks(suites)
1195
- sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1196
- "parallel": self.parallel(), "stop_early": self.query_one("#stop-early", Checkbox).value}
1215
+ total = self.repeat_total()
1216
+ sel = {"models": labels, "packs": suites, "repeat": repeat, "repeat_total": total, "tier": tier, "force": force,
1217
+ "tests": picks, "parallel": self.parallel(), "stop_early": self.query_one("#stop-early", Checkbox).value}
1197
1218
  e = self.app.engine
1198
- jobs = e.plan(labels, suites, repeat, tier, True, picks)
1219
+ jobs = e.plan(labels, suites, repeat, tier, True, picks, total=total)
1199
1220
  if not any(j.status == "waiting" for j in jobs):
1200
1221
  reasons = sorted({j.note for j in jobs if j.status == "skipped"})
1201
1222
  self.notify(f"Nothing selected can run: {'; '.join(reasons)}", severity="warning")
@@ -1203,7 +1224,7 @@ class SetupScreen(Screen):
1203
1224
  self.app.save_state({k: v for k, v in sel.items() if k != "models"}) # models aren't remembered
1204
1225
  # Packs whose earlier answers a start would set aside (Retest, everything already done, or
1205
1226
  # other questions or settings): the user picks per pack before anything moves.
1206
- rows, rerun = retest_rows(e, labels, suites, repeat, tier, picks, force)
1227
+ rows, rerun = retest_rows(e, labels, suites, repeat, tier, picks, force, total)
1207
1228
 
1208
1229
  def go(choices):
1209
1230
  if choices is None:
@@ -1631,7 +1652,7 @@ class PresetsScreen(ModalScreen):
1631
1652
  name = list(self.presets)[event.option_index]
1632
1653
  labels, packs, repeat, tests = engine.resolve_preset(self.app.engine.cfg, self.presets[name],
1633
1654
  list(self.app.engine.packs))
1634
- self.dismiss(("load", {"models": labels, "packs": packs, "repeat": repeat, "tests": tests}))
1655
+ self.dismiss(("load", {"models": labels, "packs": packs, "repeat": repeat, "repeat_total": True, "tests": tests}))
1635
1656
 
1636
1657
  @on(Button.Pressed, "#save")
1637
1658
  @on(Input.Submitted, "#name")
@@ -1756,7 +1777,8 @@ class QueuedRun:
1756
1777
  """(jobs, None), or (None, why it can't run) now."""
1757
1778
  sel = self.sel
1758
1779
  jobs = [j for j in eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], self.retest,
1759
- self.tests(eng), sel.get("stop_early") or None) if j.key not in self.leave]
1780
+ self.tests(eng), sel.get("stop_early") or None, sel.get("repeat_total", False))
1781
+ if j.key not in self.leave]
1760
1782
  waiting = [j for j in jobs if j.status == "waiting"]
1761
1783
  if not waiting:
1762
1784
  return None, "every pack left is already done"
@@ -1841,13 +1863,13 @@ class RetestRow:
1841
1863
  CHOICE_TEXT = {"zero": "[b red]Zero[/b red]", "continue": "[b green]Continue[/b green]", "leave": "[dim]Leave out[/dim]"}
1842
1864
 
1843
1865
 
1844
- def retest_rows(eng, labels, packs, repeat, tier, picks, force):
1866
+ def retest_rows(eng, labels, packs, repeat, tier, picks, force, total=False):
1845
1867
  """(rows, rerun): the packs Start would set earlier answers aside for, or could. rerun: Retest is
1846
1868
  off but everything selected is done, so Start runs it again. A pack whose questions or settings
1847
1869
  changed can't continue (Zero or Leave out); with Retest every pack with answers starts at Zero,
1848
1870
  without it a pack that can resume continues."""
1849
- base = eng.plan(labels, packs, repeat, tier, False, picks)
1850
- zero = {j.key: j for j in eng.plan(labels, packs, repeat, tier, True, picks)}
1871
+ base = eng.plan(labels, packs, repeat, tier, False, picks, total=total)
1872
+ zero = {j.key: j for j in eng.plan(labels, packs, repeat, tier, True, picks, total=total)}
1851
1873
  rerun = not force and not any(j.status == "waiting" for j in base)
1852
1874
  rows = []
1853
1875
  for b in base:
@@ -3136,7 +3158,10 @@ class ResultsScreen(Screen):
3136
3158
  base = column_name(col.label)
3137
3159
  label = base + SORT_ARROWS[desc] if base == name else base
3138
3160
  if col.label.plain != label:
3139
- col.label = Text(label)
3161
+ text = col.label.copy() # keeps its styling, e.g. a model's ×n
3162
+ if len(text.plain) > len(base): # right_crop(0) would empty it
3163
+ text.right_crop(len(text.plain) - len(base))
3164
+ col.label = text + SORT_ARROWS[desc] if base == name else text
3140
3165
  w = cell_len(label) # room for the arrow (a column keeps the width it was added with)
3141
3166
  col.content_width = max(col.content_width, w)
3142
3167
  if not col.auto_width:
@@ -3404,7 +3429,9 @@ class ResultsScreen(Screen):
3404
3429
  here = self.app.engine.machine().id
3405
3430
  table = self.query_one("#per-question", DataTable)
3406
3431
  table.clear(columns=True)
3407
- table.add_columns(*self.PQ_FIXED, *models)
3432
+ table.add_columns(*self.PQ_FIXED, *(Text.from_markup(
3433
+ f"{rich_escape(m)} {repeats_tag([t['cells'][m]['n'] for t in tests if m in t['cells']])}".rstrip())
3434
+ for m in models))
3408
3435
  self.pq_tests, self.pq_models = {}, models
3409
3436
  for t in tests:
3410
3437
  cells = []
@@ -3427,7 +3454,8 @@ class ResultsScreen(Screen):
3427
3454
  "[green]bold[/green] = fewest tokens among passing answers · [red]cut[/red] = hit max_tokens · "
3428
3455
  "[yellow]over[/yellow] = ran past max_tokens (the provider didn't stop it) · "
3429
3456
  "◐ = pack unfinished (not in scores) · dim seconds = hosted, or measured on another machine; tokens compare "
3430
- "across machines, seconds don't. ×tokens = most / fewest tokens across models. Enter on a cell opens the "
3457
+ "across machines, seconds don't. ×tokens = most / fewest tokens across models. ×n beside a model: answers "
3458
+ "per question (×1: one answer decides each result, the roughest). Enter on a cell opens the "
3431
3459
  "answers. Click a column name to sort by it (a model's column: by its tokens).[/dim]" + ("\n[yellow]Left out, being rerun with other questions or settings: " + ", ".join(
3432
3460
  f"{os.path.basename(os.path.dirname(p))} · {os.path.basename(p)[:-5]}" for p in sorted(self.superseded))
3433
3461
  + ". Only the new run's answers (◐) are shown for these.[/yellow]" if self.superseded else ""))
@@ -3469,6 +3497,7 @@ class ResultsScreen(Screen):
3469
3497
  def hi(v, text, key, good):
3470
3498
  ref = best.get(key) if good == "max" else low.get(key)
3471
3499
  return f"[bold green]{text}[/bold green]" if v is not None and v == ref and len(rows) > 1 else text
3500
+ tags = {m: repeats_tag([t["cells"][m]["n"] for t in shared]) for m in models}
3472
3501
  cells = []
3473
3502
  for r in rows:
3474
3503
  tt = r["total_secs"]
@@ -3489,7 +3518,10 @@ class ResultsScreen(Screen):
3489
3518
  (f"[red]{r['cut']}[/red]" if r["cut"] else "0")
3490
3519
  + (f" [yellow]+{r['over']} over[/yellow]" if r.get("over") else "")])
3491
3520
  table.add_columns(*header)
3492
- table.add_rows(fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3))
3521
+ tag_w = max(Text.from_markup(tag).cell_len + 1 for tag in tags.values())
3522
+ cells = fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3 - tag_w,
3523
+ floor=24 - tag_w) # the column's floor counts the ×n too
3524
+ table.add_rows([[f"{rich_escape(c[0])} {tags[r['model']]}"] + c[1:] for c, r in zip(cells, rows)])
3493
3525
  line = f"[b]Comparison[/b] over the {len(shared)} questions all {len(models)} answered"
3494
3526
  if len(models) == 2:
3495
3527
  h = compare.head_to_head(shared, *models)
@@ -3506,7 +3538,7 @@ class ResultsScreen(Screen):
3506
3538
  title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
3507
3539
  "per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
3508
3540
  "they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Fewest / Fastest: questions won; ties count for both. "
3509
- "Cut off: answers that hit max_tokens.[/dim]")
3541
+ "Cut off: answers that hit max_tokens. ×n beside a name: answers per question.[/dim]")
3510
3542
 
3511
3543
  @staticmethod
3512
3544
  def pq_cell(c, best, here):
@@ -711,6 +711,117 @@ class Retest(unittest.TestCase):
711
711
  self.assertEqual(self.passes("apps"), dict.fromkeys("abcd", True))
712
712
 
713
713
 
714
+ class AddRepeats(unittest.TestCase):
715
+ """A chosen repeat count adds rounds to the saved answers (×1 + 2 = ×3); a retest, blank and a
716
+ resumed run don't add more."""
717
+
718
+ def setUp(self):
719
+ self.tmp = tempfile.TemporaryDirectory()
720
+ self.ws = os.path.join(self.tmp.name, "ws")
721
+ tuieval(self.tmp.name, "init", self.ws)
722
+ d = os.path.join(self.ws, "packs", "apps")
723
+ os.makedirs(d)
724
+ write(os.path.join(d, "pack.toml"), 'label = "apps"\nscreen = 4\n[certify]\nrepeat = 1\n'
725
+ '[gate]\nmin_accuracy = 0.1\n')
726
+ write(os.path.join(d, "tests.yaml"), "".join(
727
+ f"- {{id: {t}, input: question {t}, expected: 1, reference: 'ANSWER: 1', wrong: ['ANSWER: 2'], "
728
+ "difficulty: easy}\n" for t in "abcd"))
729
+ port = free_port()
730
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
731
+ f.write(f'\n[servers.m]\nurl = "http://127.0.0.1:{port}"\n')
732
+ self.mock = Mock(os.path.join(self.ws, "packs"), "oracle", port=port)
733
+ tuieval(self.ws, "add", "mock", "--server", "m", "--label", "m")
734
+ tuieval(self.ws, "run", "--tier", "certify")
735
+ self.path = os.path.join(self.ws, "results", "m", "apps.json")
736
+
737
+ def tearDown(self):
738
+ self.mock.stop()
739
+ self.tmp.cleanup()
740
+
741
+ def rounds(self):
742
+ out = {}
743
+ for r in json.loads(read(self.path))["results"]:
744
+ out.setdefault(r["test"], set()).add(r["repeat"])
745
+ return {t: sorted(v) for t, v in out.items()}
746
+
747
+ def test_cli_adds_rounds(self):
748
+ out = tuieval(self.ws, "run", "--tier", "certify", "--repeat", "2").stdout
749
+ self.assertIn("×1 → ×3", out)
750
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1, 2]))
751
+ tuieval(self.ws, "run", "--tier", "certify") # blank: the pack's 1, done
752
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1, 2]))
753
+
754
+ def test_retest_counts_from_none(self):
755
+ tuieval(self.ws, "run", "--tier", "certify", "--repeat", "2", "--retest")
756
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1]))
757
+
758
+ def test_picked_tests_then_top_up(self):
759
+ tuieval(self.ws, "run", "--tier", "certify", "--tests", "apps:a", "--repeat", "1")
760
+ self.assertEqual(self.rounds(), {"a": [0, 1], "b": [0], "c": [0], "d": [0]})
761
+ tuieval(self.ws, "run", "--tier", "certify", "--repeat", "1") # the most rounds (2) + 1, for all
762
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1, 2]))
763
+
764
+ def test_resumed_run_adds_no_more(self):
765
+ from tuieval import engine
766
+ e = engine.Engine(root=self.ws)
767
+ j = e.plan(["m"], ["apps"], 2, "certify", False, {})[0]
768
+ self.assertEqual((j.base, j.repeat, j.done), (1, 3, 4))
769
+ rows = [dict(r, repeat=1) for r in json.loads(read(self.path))["results"] if r["test"] in "ab"]
770
+ write(self.path + ".partial.jsonl", "".join(json.dumps(l) + "\n" for l in [e._partial_header(j)] + rows))
771
+ out = tuieval(self.ws, "run", "--tier", "certify", "--repeat", "2").stdout # the same +2, resumed
772
+ self.assertIn("resuming after 6", out)
773
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1, 2]))
774
+
775
+ def test_preset_repeat_is_a_total(self):
776
+ from tuieval import engine
777
+ engine.save_preset(os.path.join(self.ws, "presets.toml"), "two", ["m"], ["apps"], 2)
778
+ tuieval(self.ws, "run", "--tier", "certify", "--preset", "two")
779
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1])) # ×1 topped up to 2
780
+ tuieval(self.ws, "run", "--tier", "certify", "--preset", "two") # already 2: nothing new
781
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1]))
782
+ tuieval(self.ws, "run", "--tier", "certify", "--preset", "two", "--repeat", "1") # typed: adds
783
+ self.assertEqual(self.rounds(), dict.fromkeys("abcd", [0, 1, 2]))
784
+
785
+ def estimate(self, steps):
786
+ code = textwrap.dedent("""
787
+ import asyncio
788
+ from tuieval.tui import EvalsApp
789
+ from textual.widgets import SelectionList, Input
790
+ async def go():
791
+ app = EvalsApp({})
792
+ async with app.run_test(size=(160, 50)) as pilot:
793
+ await pilot.pause()
794
+ s = app.screen
795
+ s.query_one("#suites", SelectionList).select("apps")
796
+ s.selected_models = {"m"}
797
+ s.refresh_models()
798
+ s.query_one("#tier-certify").value = True
799
+ %s
800
+ await pilot.pause()
801
+ print(str(s.query_one("#estimate").render()))
802
+ asyncio.run(go())
803
+ """) % textwrap.indent(textwrap.dedent(steps), " " * 8)
804
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
805
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
806
+ return p.stdout + p.stderr
807
+
808
+ def test_tui_estimate_says_more(self):
809
+ self.assertIn("2 more (×1 → ×3) repeat(s) = 8 answers", self.estimate("""
810
+ s.query_one("#repeat", Input).value = "2"
811
+ """))
812
+
813
+ def test_tui_preset_repeat_is_a_total(self):
814
+ out = self.estimate("""
815
+ s.apply_selection({"models": ["m"], "packs": ["apps"], "repeat": 2, "repeat_total": True}, quiet=True)
816
+ s.query_one("#tier-certify").value = True
817
+ await pilot.pause()
818
+ print(str(s.query_one("#estimate").render()))
819
+ s.query_one("#repeat", Input).value = "3" # typed over: adds again
820
+ """)
821
+ self.assertIn("2 in all (preset) repeat(s) = 4 answers", out)
822
+ self.assertIn("3 more (×1 → ×4) repeat(s) = 12 answers", out)
823
+
824
+
714
825
  class Parallel(unittest.TestCase):
715
826
  """Several models at a time (parallel_models / --parallel): each runs in its own lane on its own
716
827
  port, answers note the models they ran alongside, and the scheduler keeps the memory and
@@ -1739,7 +1850,7 @@ class PTAIndex(unittest.TestCase):
1739
1850
  print(t.row_count, str(s.query_one("#fail-count", Static).render()))
1740
1851
  s.fill_per_question()
1741
1852
  pq = s.query_one("#per-question", DataTable)
1742
- cols = [str(c.label) for c in pq.ordered_columns]
1853
+ cols = [str(c.label).split(" ×")[0] for c in pq.ordered_columns]
1743
1854
  print(sorted(str(pq.get_row_at(i)[cols.index("close")]).split()[1] for i in range(pq.row_count)))
1744
1855
  asyncio.run(go())
1745
1856
  """)
@@ -1801,7 +1912,7 @@ class PTAIndex(unittest.TestCase):
1801
1912
  s = app.screen
1802
1913
  def state():
1803
1914
  pq = s.query_one("#per-question", DataTable)
1804
- cols = [str(c.label) for c in pq.ordered_columns][len(s.PQ_FIXED):]
1915
+ cols = [str(c.label).split(" ×")[0] for c in pq.ordered_columns][len(s.PQ_FIXED):]
1805
1916
  return (sorted(app.pq_models), s.query_one("#pta-pack", Select).value,
1806
1917
  str(s.query_one("#pq-models", Button).label), cols, s.query_one("#pq-shared").value)
1807
1918
  s.query_one("#pq-pack", Select).value = "apps"
@@ -1955,7 +2066,7 @@ class PTAIndex(unittest.TestCase):
1955
2066
  pq.move_cursor(row=0, column=col)
1956
2067
  await pilot.press("enter")
1957
2068
  await pilot.pause(0.3)
1958
- opened[str(pq.ordered_columns[col].label.plain)] = app.screen.entries[0]["model"]
2069
+ opened[pq.ordered_columns[col].label.plain.split(" ×")[0]] = app.screen.entries[0]["model"]
1959
2070
  app.screen.dismiss()
1960
2071
  await pilot.pause(0.2)
1961
2072
  print(json.dumps({"rows": rows, "bars": "P parsimony" in tri, "disagree": [both, alone], "opened": opened,
@@ -2271,6 +2382,7 @@ class FirstRun(unittest.TestCase):
2271
2382
  tuieval(self.ws, "add", "my-model", "--server", "local")
2272
2383
  code = textwrap.dedent("""
2273
2384
  import asyncio
2385
+ from rich.text import Text
2274
2386
  from tuieval import compare
2275
2387
  from tuieval.tui import EvalsApp, ResultsScreen
2276
2388
  from textual.widgets import DataTable, TabbedContent
@@ -2288,14 +2400,56 @@ class FirstRun(unittest.TestCase):
2288
2400
  app.screen.fill_pq_summary(compare.per_question(rows), models)
2289
2401
  await pilot.pause(0.3)
2290
2402
  t = app.screen.query_one("#pq-summary", DataTable)
2291
- names = [str(t.get_row_at(i)[0]) for i in range(t.row_count)]
2403
+ names = [Text.from_markup(t.get_row_at(i)[0]).plain for i in range(t.row_count)]
2292
2404
  print(t.row_count, t.region.height, t.virtual_size.width <= t.region.width,
2293
- all(n.endswith("-q4_k_m-vllm") and "…" in n for n in names), len(set(names)))
2405
+ all(n.endswith("-q4_k_m-vllm ×1") and "…" in n for n in names), len(set(names)))
2406
+ asyncio.run(go())
2407
+ """)
2408
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
2409
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
2410
+ # every row, no scroll; a trimmed name keeps its ×n
2411
+ self.assertEqual(out.stdout.strip(), "12 13 True True 12", out.stderr[-2000:])
2412
+
2413
+ def test_per_question_shows_repeats(self):
2414
+ tuieval(self.ws, "add", "my-model", "--server", "local")
2415
+ code = textwrap.dedent("""
2416
+ import asyncio
2417
+ from tuieval.tui import EvalsApp, ResultsScreen
2418
+ from textual.widgets import DataTable, TabbedContent
2419
+ rows = [{"model": m, "suite": "p", "test": f"p: q{q}", "ok": True, "latency": 1000, "tokens": 10,
2420
+ "truncated": False, "gen_tps": 20.0, "prompt_tps": None, "repeat": r, "score": 1.0}
2421
+ for m, reps in (("once", 1), ("thrice", 3)) for q in range(2) for r in range(reps)]
2422
+ rows += [dict(rows[-1], test="p: q2", repeat=0)] # one more question, answered once
2423
+ async def go():
2424
+ app = EvalsApp({})
2425
+ async with app.run_test(size=(160, 40)) as pilot:
2426
+ await pilot.pause()
2427
+ app.push_screen(ResultsScreen())
2428
+ await pilot.pause(0.5)
2429
+ s = app.screen
2430
+ s.rows, s.partial_rows, s.superseded = rows, [], set()
2431
+ s.query_one(TabbedContent).active = "tab-per-question"
2432
+ await pilot.pause(0.3)
2433
+ s.fill_per_question()
2434
+ await pilot.pause(0.3)
2435
+ pq = s.query_one("#per-question", DataTable)
2436
+ heads = [c.label for c in pq.ordered_columns][len(s.PQ_FIXED):]
2437
+ print([h.plain for h in heads], [str(h.spans[0].style) for h in heads])
2438
+ app.result_sorts["per-question"] = ("thrice ×1-3", True) # as a click on its name does
2439
+ s.sort_table(pq)
2440
+ head = pq.ordered_columns[-1].label
2441
+ print(head.plain, str(head.spans[0].style))
2442
+ t = s.query_one("#pq-summary", DataTable)
2443
+ print([t.get_row_at(i)[0] for i in range(t.row_count)])
2294
2444
  asyncio.run(go())
2295
2445
  """)
2296
2446
  out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
2297
2447
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
2298
- self.assertEqual(out.stdout.strip(), "12 13 True True 12", out.stderr[-2000:]) # every row, no scroll
2448
+ self.assertEqual(out.stdout.strip().splitlines(), [
2449
+ "['once ×1', 'thrice ×1-3'] ['yellow', 'dim']", # over the questions shown
2450
+ "thrice ×1-3 ▼ dim", # sorting keeps the ×n's colour
2451
+ "['once [yellow]×1[/]', 'thrice [dim]×3[/]']", # Comparison: only the questions both answered
2452
+ ], out.stderr[-2000:])
2299
2453
 
2300
2454
  def test_results_open_on_pta_index(self):
2301
2455
  tuieval(self.ws, "add", "my-model", "--server", "local")
File without changes
File without changes