tuieval 0.2.0.dev137__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.dev137 → tuieval-0.2.0.dev139}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/models.md +1 -1
  4. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/engine.py +23 -3
  6. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/run_evals.py +9 -4
  7. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/tui.py +27 -14
  8. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/tests/test_tuieval.py +111 -0
  9. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/.gitignore +0 -0
  10. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/LICENSE +0 -0
  11. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/README.md +0 -0
  12. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/RELEASING.md +0 -0
  13. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/agent-packs.md +0 -0
  14. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/README.md +0 -0
  15. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/favicon.ico +0 -0
  16. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/readme-header.png +0 -0
  17. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/social-preview.png +0 -0
  18. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-1024.png +0 -0
  19. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-128.png +0 -0
  20. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-16.png +0 -0
  21. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-16.svg +0 -0
  22. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-256.png +0 -0
  23. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-32.png +0 -0
  24. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-48.png +0 -0
  25. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-512.png +0 -0
  26. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-64.png +0 -0
  27. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  28. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/brand/tuieval-icon.svg +0 -0
  29. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/tui-answer-reasoning.png +0 -0
  30. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/tui-run-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/images/tui-setup.png +0 -0
  32. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/docs/writing-packs.md +0 -0
  33. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/pyproject.toml +0 -0
  34. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/__init__.py +0 -0
  35. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/__main__.py +0 -0
  36. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/agents.py +0 -0
  37. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/builtin.toml +0 -0
  38. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/cli.py +0 -0
  39. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/client.py +0 -0
  40. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/compare.py +0 -0
  41. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/dates.py +0 -0
  42. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/doctor.py +0 -0
  43. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/export.py +0 -0
  44. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/graders/__init__.py +0 -0
  45. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/graders/answer.py +0 -0
  46. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/graders/code.py +0 -0
  47. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/graders/rag.py +0 -0
  48. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/graders/reply.py +0 -0
  49. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/graders/tool_call.py +0 -0
  50. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/insights.py +0 -0
  51. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/logs.py +0 -0
  52. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/machines.py +0 -0
  53. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/onboard.py +0 -0
  54. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/packs.py +0 -0
  55. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/profiles.py +0 -0
  56. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/pta.py +0 -0
  57. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev137 → tuieval-0.2.0.dev139}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
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
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.
@@ -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.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.dev137'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev137')
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
 
@@ -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:
@@ -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
File without changes
File without changes