tuieval 0.2.0.dev137__tar.gz → 0.2.0.dev141__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.dev137 → tuieval-0.2.0.dev141}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/models.md +1 -1
  4. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/compare.py +3 -1
  6. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/engine.py +23 -3
  7. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/run_evals.py +9 -4
  8. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/tui.py +35 -18
  9. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/tests/test_tuieval.py +157 -0
  10. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/.gitignore +0 -0
  11. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/LICENSE +0 -0
  12. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/README.md +0 -0
  13. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/RELEASING.md +0 -0
  14. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/agent-packs.md +0 -0
  15. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/tui-answer-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/tui-run-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/images/tui-setup.png +0 -0
  33. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/docs/writing-packs.md +0 -0
  34. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/agents.py +0 -0
  38. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/builtin.toml +0 -0
  39. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/cli.py +0 -0
  40. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/client.py +0 -0
  41. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/dates.py +0 -0
  42. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/doctor.py +0 -0
  43. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/export.py +0 -0
  44. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/graders/__init__.py +0 -0
  45. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/graders/answer.py +0 -0
  46. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/graders/code.py +0 -0
  47. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/graders/rag.py +0 -0
  48. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/graders/reply.py +0 -0
  49. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/graders/tool_call.py +0 -0
  50. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/insights.py +0 -0
  51. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/logs.py +0 -0
  52. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/machines.py +0 -0
  53. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/onboard.py +0 -0
  54. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/packs.py +0 -0
  55. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/profiles.py +0 -0
  56. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/pta.py +0 -0
  57. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev141}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Per question → Comparison: a **Pass rate** column beside Passed, the share of answers right with repeats included (shown when some model has repeats).
6
+ - 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
7
  - 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
8
  - 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
9
  - 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev137
3
+ Version: 0.2.0.dev141
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.dev137'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev137')
21
+ __version__ = version = '0.2.0.dev141'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev141')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -420,7 +420,8 @@ def complete_models(rows, suite, test_ids=None):
420
420
  def compare_models(tests, models):
421
421
  """Per model over per_question entries that all `models` answered: passes, token and time
422
422
  totals/medians (totals add each question's median over repeats, so repeats don't inflate them), questions where it used the fewest tokens / was fastest (among passing
423
- answers for tokens; ties count for each), answers cut off, mean score (partial credit)."""
423
+ answers for tokens; ties count for each), answers cut off, mean score (partial credit),
424
+ pass rate (answers right with repeats, each question weighing the same)."""
424
425
  out = []
425
426
  for m in models:
426
427
  cs = [t["cells"][m] for t in tests]
@@ -438,6 +439,7 @@ def compare_models(tests, models):
438
439
  rs = [r for c in cs for r in c["rows"]]
439
440
  out.append({"model": m, "n": len(cs), "passed": passed, "total_tokens": total,
440
441
  "score": statistics.fmean(c["score"] for c in cs) if cs else None,
442
+ "pass_rate": statistics.fmean(c["passed"] / c["n"] for c in cs) if cs else None,
441
443
  "avg_pp_tps": _rate([(r.get("prompt_tokens"), r["prompt_tps"]) for r in rs]),
442
444
  "avg_gen_tps": _rate([(r["tokens"], r["gen_tps"]) for r in rs]),
443
445
  "med_tokens": median(c["tokens"] for c in cs), "med_secs": median(c["secs"] for c in cs),
@@ -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
 
@@ -1025,6 +1025,11 @@ class SetupScreen(Screen):
1025
1025
  list(self.query_one("#suites", SelectionList).selected), repeat,
1026
1026
  self.tier(), self.query_one("#force", Checkbox).value)
1027
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
+
1028
1033
  def parallel(self):
1029
1034
  """Models at a time for the next run: the field, or blank for this machine's parallel_models."""
1030
1035
  try:
@@ -1088,16 +1093,21 @@ class SetupScreen(Screen):
1088
1093
  "[b]s[/b] to start." + self.queue_hint())
1089
1094
  return
1090
1095
  picks = self.current_picks(suites)
1091
- 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)
1092
1098
  rerun = not any(j.status == "waiting" for j in jobs)
1093
1099
  if rerun: # everything has results: Start runs it again, so estimate that
1094
- 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)
1095
1101
  run = [j for j in jobs if j.status == "waiting"]
1096
1102
  skipped = [j for j in jobs if j.status == "skipped"]
1097
1103
  secs, notes = self.app.engine.estimate_seconds(jobs)
1098
- todo = sum(j.total for j in run)
1099
- reps = sorted({j.repeat for j in jobs})
1100
- 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]"
1101
1111
  text = (f"[b]{tier.title()}[/b]: [b]{len(labels)}[/b] model(s) × [b]{len(suites)}[/b] pack(s) × "
1102
1112
  f"[b]{reps_text}[/b] repeat(s) = "
1103
1113
  f"[b]{todo:,}[/b] answers · about [b]{fmt_secs(secs)}[/b]"
@@ -1158,6 +1168,7 @@ class SetupScreen(Screen):
1158
1168
  for name in sel.get("packs", []):
1159
1169
  if name in e.packs:
1160
1170
  packs.select(name)
1171
+ self.preset_repeat = str(sel["repeat"]) if sel.get("repeat") and sel.get("repeat_total") else None
1161
1172
  self.query_one("#repeat", Input).value = str(sel["repeat"]) if sel.get("repeat") else ""
1162
1173
  if "parallel" in sel:
1163
1174
  self.query_one("#parallel", Input).value = str(sel["parallel"]) if sel["parallel"] else ""
@@ -1201,10 +1212,11 @@ class SetupScreen(Screen):
1201
1212
  self.notify("Select at least one eval pack and one model.", severity="warning")
1202
1213
  return
1203
1214
  picks = self.current_picks(suites)
1204
- sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1205
- "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}
1206
1218
  e = self.app.engine
1207
- jobs = e.plan(labels, suites, repeat, tier, True, picks)
1219
+ jobs = e.plan(labels, suites, repeat, tier, True, picks, total=total)
1208
1220
  if not any(j.status == "waiting" for j in jobs):
1209
1221
  reasons = sorted({j.note for j in jobs if j.status == "skipped"})
1210
1222
  self.notify(f"Nothing selected can run: {'; '.join(reasons)}", severity="warning")
@@ -1212,7 +1224,7 @@ class SetupScreen(Screen):
1212
1224
  self.app.save_state({k: v for k, v in sel.items() if k != "models"}) # models aren't remembered
1213
1225
  # Packs whose earlier answers a start would set aside (Retest, everything already done, or
1214
1226
  # other questions or settings): the user picks per pack before anything moves.
1215
- rows, rerun = retest_rows(e, labels, suites, repeat, tier, picks, force)
1227
+ rows, rerun = retest_rows(e, labels, suites, repeat, tier, picks, force, total)
1216
1228
 
1217
1229
  def go(choices):
1218
1230
  if choices is None:
@@ -1640,7 +1652,7 @@ class PresetsScreen(ModalScreen):
1640
1652
  name = list(self.presets)[event.option_index]
1641
1653
  labels, packs, repeat, tests = engine.resolve_preset(self.app.engine.cfg, self.presets[name],
1642
1654
  list(self.app.engine.packs))
1643
- 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}))
1644
1656
 
1645
1657
  @on(Button.Pressed, "#save")
1646
1658
  @on(Input.Submitted, "#name")
@@ -1765,7 +1777,8 @@ class QueuedRun:
1765
1777
  """(jobs, None), or (None, why it can't run) now."""
1766
1778
  sel = self.sel
1767
1779
  jobs = [j for j in eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], self.retest,
1768
- 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]
1769
1782
  waiting = [j for j in jobs if j.status == "waiting"]
1770
1783
  if not waiting:
1771
1784
  return None, "every pack left is already done"
@@ -1850,13 +1863,13 @@ class RetestRow:
1850
1863
  CHOICE_TEXT = {"zero": "[b red]Zero[/b red]", "continue": "[b green]Continue[/b green]", "leave": "[dim]Leave out[/dim]"}
1851
1864
 
1852
1865
 
1853
- def retest_rows(eng, labels, packs, repeat, tier, picks, force):
1866
+ def retest_rows(eng, labels, packs, repeat, tier, picks, force, total=False):
1854
1867
  """(rows, rerun): the packs Start would set earlier answers aside for, or could. rerun: Retest is
1855
1868
  off but everything selected is done, so Start runs it again. A pack whose questions or settings
1856
1869
  changed can't continue (Zero or Leave out); with Retest every pack with answers starts at Zero,
1857
1870
  without it a pack that can resume continues."""
1858
- base = eng.plan(labels, packs, repeat, tier, False, picks)
1859
- 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)}
1860
1873
  rerun = not force and not any(j.status == "waiting" for j in base)
1861
1874
  rows = []
1862
1875
  for b in base:
@@ -3474,10 +3487,10 @@ class ResultsScreen(Screen):
3474
3487
  self.pq_summary_args = (tests, models) # to refit on resize
3475
3488
  rows = compare.compare_models(shared, models)
3476
3489
  # short headings so every column fits; the line above the table says what each one is
3477
- header = ("Model", "Time", "Passed", "Med tok", "Tokens", "Tok/✓", "Med s", "Prompt t/s", "Gen t/s",
3478
- "Fewest", "Fastest", "Cut off")
3490
+ header = ("Model", "Time", "Passed", "Pass rate", "Med tok", "Tokens", "Tok/✓", "Med s", "Prompt t/s",
3491
+ "Gen t/s", "Fewest", "Fastest", "Cut off")
3479
3492
  best = {k: max((r[k] for r in rows if r[k] is not None), default=None)
3480
- for k in ("passed", "fewest", "fastest", "avg_pp_tps", "avg_gen_tps")}
3493
+ for k in ("passed", "pass_rate", "fewest", "fastest", "avg_pp_tps", "avg_gen_tps")}
3481
3494
  low = {k: min(r[k] for r in rows if r[k] is not None) if any(r[k] is not None for r in rows) else None
3482
3495
  for k in ("med_tokens", "total_tokens", "tokens_per_pass", "med_secs", "total_secs")}
3483
3496
 
@@ -3492,6 +3505,7 @@ class ResultsScreen(Screen):
3492
3505
  hi(tt, ("-" if not tt else f"{tt:.1f}s" if tt < 60 else fmt_secs(tt)), "total_secs", "min"),
3493
3506
  hi(r["passed"], f"{r['passed']}/{r['n']}", "passed", "max")
3494
3507
  + (f" [dim]{r['score']:.0%}[/dim]" if r["passed"] < r["n"] and r["score"] else ""),
3508
+ hi(r["pass_rate"], f"{r['pass_rate']:.0%}", "pass_rate", "max"),
3495
3509
  hi(r["med_tokens"], f"{r['med_tokens']:,.0f}" if r["med_tokens"] else "-", "med_tokens", "min"),
3496
3510
  hi(r["total_tokens"], f"{r['total_tokens']:,.0f}", "total_tokens", "min"),
3497
3511
  hi(r["tokens_per_pass"], f"{r['tokens_per_pass']:,.0f}" if r["tokens_per_pass"] else "-",
@@ -3504,6 +3518,9 @@ class ResultsScreen(Screen):
3504
3518
  hi(r["fastest"], str(r["fastest"]), "fastest", "max"),
3505
3519
  (f"[red]{r['cut']}[/red]" if r["cut"] else "0")
3506
3520
  + (f" [yellow]+{r['over']} over[/yellow]" if r.get("over") else "")])
3521
+ if all(t["cells"][m]["n"] == 1 for t in shared for m in models): # one answer each: Passed says it all
3522
+ i = header.index("Pass rate")
3523
+ header, cells = header[:i] + header[i + 1:], [c[:i] + c[i + 1:] for c in cells]
3507
3524
  table.add_columns(*header)
3508
3525
  tag_w = max(Text.from_markup(tag).cell_len + 1 for tag in tags.values())
3509
3526
  cells = fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3 - tag_w,
@@ -3524,7 +3541,7 @@ class ResultsScreen(Screen):
3524
3541
  "shared the machine's speed: compare their times with care.[/yellow]")
3525
3542
  title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
3526
3543
  "per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
3527
- "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. "
3544
+ "they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Pass rate (with repeats): answers right, repeats included (each question weighs the same). Fewest / Fastest: questions won; ties count for both. "
3528
3545
  "Cut off: answers that hit max_tokens. ×n beside a name: answers per question.[/dim]")
3529
3546
 
3530
3547
  @staticmethod
@@ -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
@@ -2340,6 +2451,52 @@ class FirstRun(unittest.TestCase):
2340
2451
  "['once [yellow]×1[/]', 'thrice [dim]×3[/]']", # Comparison: only the questions both answered
2341
2452
  ], out.stderr[-2000:])
2342
2453
 
2454
+ def test_pass_rate_counts_every_answer(self):
2455
+ from tuieval import compare
2456
+ row = {"suite": "p", "latency": 1000, "tokens": 10, "truncated": False, "gen_tps": 20.0, "prompt_tps": None}
2457
+ oks = {"a": {"q0": [True, True], "q1": [True, False], "q2": [False, False, False, True]}, # ×2, ×2, ×4
2458
+ "b": {"q0": [True], "q1": [False], "q2": [False]},
2459
+ "c": {"q0": [False], "q1": [False], "q2": [False]}}
2460
+ rows = [dict(row, model=m, test=f"p: {q}", ok=ok, repeat=r)
2461
+ for m, qs in oks.items() for q, v in qs.items() for r, ok in enumerate(v)]
2462
+ out = {r["model"]: r for r in compare.compare_models(compare.per_question(rows), list(oks))}
2463
+ self.assertEqual(out["a"]["passed"], 1) # strict: right on every repeat
2464
+ self.assertAlmostEqual(out["a"]["pass_rate"], (1 + 0.5 + 0.25) / 3) # each question weighs the same
2465
+ self.assertAlmostEqual(out["b"]["pass_rate"], 1 / 3) # ×1: the same as passed / questions
2466
+ self.assertEqual(out["c"]["pass_rate"], 0)
2467
+
2468
+ def test_comparison_shows_pass_rate(self):
2469
+ tuieval(self.ws, "add", "my-model", "--server", "local")
2470
+ code = textwrap.dedent("""
2471
+ import asyncio
2472
+ from rich.text import Text
2473
+ from tuieval import compare
2474
+ from tuieval.tui import EvalsApp, ResultsScreen
2475
+ from textual.widgets import DataTable, TabbedContent
2476
+ oks = {"steady": [[True, True], [True, True]], "shaky": [[True, True], [True, False]]}
2477
+ rows = [{"model": m, "suite": "p", "test": f"p: q{q}", "ok": ok, "latency": 1000, "tokens": 10,
2478
+ "truncated": False, "gen_tps": 20.0, "prompt_tps": None, "repeat": r}
2479
+ for m, qs in oks.items() for q, v in enumerate(qs) for r, ok in enumerate(v)]
2480
+ async def go():
2481
+ app = EvalsApp({})
2482
+ async with app.run_test(size=(160, 40)) as pilot:
2483
+ await pilot.pause()
2484
+ app.push_screen(ResultsScreen())
2485
+ await pilot.pause(0.5)
2486
+ app.screen.query_one(TabbedContent).active = "tab-per-question"
2487
+ await pilot.pause(0.3)
2488
+ app.screen.fill_pq_summary(compare.per_question(rows), list(oks))
2489
+ await pilot.pause(0.3)
2490
+ t = app.screen.query_one("#pq-summary", DataTable)
2491
+ i = [str(c.label) for c in t.ordered_columns].index("Pass rate")
2492
+ print(str(t.ordered_columns[i - 1].label), [t.get_row_at(n)[i] for n in range(t.row_count)])
2493
+ asyncio.run(go())
2494
+ """)
2495
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
2496
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
2497
+ # right after Passed; the best in bold green
2498
+ self.assertEqual(out.stdout.strip(), "Passed ['[bold green]100%[/bold green]', '75%']", out.stderr[-2000:])
2499
+
2343
2500
  def test_results_open_on_pta_index(self):
2344
2501
  tuieval(self.ws, "add", "my-model", "--server", "local")
2345
2502
  code = textwrap.dedent("""
File without changes
File without changes