tuieval 0.2.0.dev70__tar.gz → 0.2.0.dev74__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 (78) hide show
  1. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/_version.py +2 -2
  4. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/engine.py +48 -10
  5. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/run_evals.py +7 -1
  6. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/tui.py +177 -35
  7. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/tests/test_tuieval.py +157 -0
  8. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/README.md +0 -0
  11. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/RELEASING.md +0 -0
  12. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/README.md +0 -0
  13. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/favicon.ico +0 -0
  14. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/readme-header.png +0 -0
  15. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/social-preview.png +0 -0
  16. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-1024.png +0 -0
  17. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-128.png +0 -0
  18. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-16.png +0 -0
  19. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-16.svg +0 -0
  20. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-256.png +0 -0
  21. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-32.png +0 -0
  22. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-48.png +0 -0
  23. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-512.png +0 -0
  24. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-64.png +0 -0
  25. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  26. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/brand/tuieval-icon.svg +0 -0
  27. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/tui-answer-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/tui-run-reasoning.png +0 -0
  29. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/images/tui-setup.png +0 -0
  30. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/models.md +0 -0
  31. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/docs/writing-packs.md +0 -0
  32. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/pyproject.toml +0 -0
  33. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/__init__.py +0 -0
  34. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/__main__.py +0 -0
  35. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/builtin.toml +0 -0
  36. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/cli.py +0 -0
  37. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/client.py +0 -0
  38. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/compare.py +0 -0
  39. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/doctor.py +0 -0
  40. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/export.py +0 -0
  41. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/graders/__init__.py +0 -0
  42. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/graders/answer.py +0 -0
  43. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/graders/code.py +0 -0
  44. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/graders/rag.py +0 -0
  45. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/graders/reply.py +0 -0
  46. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/graders/tool_call.py +0 -0
  47. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/scaffold.py +0 -0
  55. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/selftest.py +0 -0
  56. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/models.toml +0 -0
  57. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  58. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/answer/system.txt +0 -0
  59. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  60. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/code/pack.toml +0 -0
  61. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/code/system.txt +0 -0
  62. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  63. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  64. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/rag/system.txt +0 -0
  65. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  66. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  67. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/reply/system.txt +0 -0
  68. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  69. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  70. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  71. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  72. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  73. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/tune.py +0 -0
  74. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/verdict.py +0 -0
  75. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev70 → tuieval-0.2.0.dev74}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Retest asks per pack before it starts: from zero, continue or leave out. It now really starts from zero, and replaced smoke results go to history/.
6
+ - Retest: the setup checkbox "Rerun existing results" is now "Retest (ask again, even if already answered)", and `tuieval run --retest` is the same (`--force` still works).
5
7
  - `tuieval logs` follows the server log of the model running now and moves on with the run; `logs live` shows each answer as it finishes, `--list` every log.
6
8
  - Results tables sort by a click on a column name; a second click reverses. Numbers, times and levels sort by value; empty cells stay last.
7
9
  - Nvidia and AMD GPUs on Linux: VRAM from `nvidia-smi`, `amd-smi` or `rocm-smi` sizes models (`gpu_memory_gb` overrides it), runs record peak VRAM, and `tuieval doctor` shows the GPU.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev70
3
+ Version: 0.2.0.dev74
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
@@ -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.dev70'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev70')
21
+ __version__ = version = '0.2.0.dev74'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev74')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -427,6 +427,7 @@ class Job:
427
427
  settings: dict = dataclasses.field(default_factory=dict) # sampling + serving identity (fingerprinted)
428
428
  merge: bool = True # keep matching earlier results and only run what's missing
429
429
  redo: bool = False # rerun the picked tests, keeping the pack's other answers
430
+ fresh: bool = False # retest: an unfinished run's matching answers are set aside too, not resumed
430
431
  status: str = "waiting" # waiting, loading, running, done, failed, skipped
431
432
  note: str = ""
432
433
  discards: list = dataclasses.field(default_factory=list) # [(what, answers, why)] set aside on start
@@ -1059,6 +1060,8 @@ class Engine:
1059
1060
  lines = [json.loads(l) for l in f if l.strip()]
1060
1061
  if lines and self._header_ok(lines[0], job):
1061
1062
  keys |= {(r["test"], r["repeat"]) for r in lines[1:] if not client.server_error_row(r)}
1063
+ if job.fresh: # a retest asks these again
1064
+ keys = {k for k in keys if k[0] not in self._redo_ids(job)}
1062
1065
  return keys & wanted
1063
1066
 
1064
1067
  def _stored(self, job):
@@ -1119,6 +1122,20 @@ class Engine:
1119
1122
  out.append(("unfinished", len(rows), why))
1120
1123
  return out
1121
1124
 
1125
+ def set_aside(self, job):
1126
+ """(finished, unfinished): earlier answers a retest of this job asks again, so they move to
1127
+ history/ and stop counting: in its result file and its unfinished run, under the job's
1128
+ questions and settings (only the picked tests' when it redoes those)."""
1129
+ redo = self._redo_ids(job)
1130
+ data = self._result(job.out_path)
1131
+ finished = 0
1132
+ if data and data["pack"]["fingerprint"] == job.pack.fingerprint and settings_match(data, job.settings):
1133
+ finished = sum(r["test"] in redo for r in data["results"])
1134
+ head, rows = self._partial_lines(job.out_path + ".partial.jsonl")
1135
+ unfinished = sum(r["test"] in redo and not client.server_error_row(r) for r in rows) \
1136
+ if self._header_ok(head, job) else 0
1137
+ return finished, unfinished
1138
+
1122
1139
  def superseded(self, path):
1123
1140
  """Is this result file about to be replaced? True when it no longer matches the current
1124
1141
  questions or settings and an unfinished run of the same pack does."""
@@ -1141,7 +1158,8 @@ class Engine:
1141
1158
  screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
1142
1159
  those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
1143
1160
  matching results are kept and only missing answers are run, unless force; force with
1144
- picked tests reruns those and keeps the pack's other answers."""
1161
+ picked tests reruns those and keeps the pack's other answers. force is True, False, or the
1162
+ keys ("label/pack") of the jobs to retest, the others resuming."""
1145
1163
  if tier not in TIERS:
1146
1164
  raise ValueError(f"tier must be one of {TIERS}")
1147
1165
  jobs = []
@@ -1151,9 +1169,11 @@ class Engine:
1151
1169
  pack = self.packs[name]
1152
1170
  reps = repeat or self.default_repeat(pack, tier)
1153
1171
  picked = (tests or {}).get(name)
1172
+ retest = force if isinstance(force, bool) else f"{label}/{name}" in force
1154
1173
  job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
1155
1174
  self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
1156
- merge=not force or bool(picked), redo=force and bool(picked), settings=self.settings(m))
1175
+ merge=not retest or bool(picked), redo=retest and bool(picked), fresh=retest,
1176
+ settings=self.settings(m))
1157
1177
  sv = self.serving(m)
1158
1178
  status = self.result_status(label, name, tier)
1159
1179
  job.discards = self._discards(job)
@@ -1171,11 +1191,11 @@ class Engine:
1171
1191
  job.status, job.note = "skipped", f"{', '.join(missing)} not installed in tuieval's Python"
1172
1192
  elif status and status.startswith("outdated"):
1173
1193
  job.merge, job.redo, job.note = False, False, status.split(": ", 1)[-1] + ", rerunning"
1174
- if not force: # a rerun under the new settings already under way resumes
1194
+ if not retest: # a rerun under the new settings already under way resumes
1175
1195
  self._fill_progress(job)
1176
1196
  if job.done:
1177
1197
  job.note += f" ({job.done} of {job.total} done)"
1178
- elif not force:
1198
+ elif not retest:
1179
1199
  self._fill_progress(job)
1180
1200
  if job.done >= job.total:
1181
1201
  job.status, job.note = "done", "done earlier"
@@ -2092,11 +2112,16 @@ class Engine:
2092
2112
  lines = [json.loads(l) for l in f if l.strip()]
2093
2113
  if lines and self._header_ok(lines[0], job):
2094
2114
  new = [r for r in lines[1:] if not client.server_error_row(r)]
2115
+ if job.fresh: # a retest asks again what the unfinished run already answered
2116
+ stay = [r for r in new if r["test"] not in self._redo_ids(job)]
2117
+ if len(stay) < len(new):
2118
+ self._partial_to_history(job, keep=bool(stay))
2119
+ new = stay
2120
+ if stay:
2121
+ with open(partial, "w") as f:
2122
+ f.write("".join(json.dumps(l) + "\n" for l in [lines[0]] + stay))
2095
2123
  else: # other questions or settings: kept in history/, not deleted
2096
- hist = os.path.join(os.path.dirname(job.out_path), "history")
2097
- os.makedirs(hist, exist_ok=True)
2098
- stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(partial)))
2099
- os.replace(partial, os.path.join(hist, f"{job.pack.name}-{stamp}.partial.jsonl"))
2124
+ self._partial_to_history(job)
2100
2125
  if not os.path.isfile(partial):
2101
2126
  with contextlib.suppress(OSError): # a fresh start: sittings of a discarded partial don't count
2102
2127
  os.remove(self._sittings_path(job))
@@ -2224,10 +2249,23 @@ class Engine:
2224
2249
  job.note = f"{job.passed}/{job.done} passed"
2225
2250
  self.emit("job_done", job=job)
2226
2251
 
2252
+ def _partial_to_history(self, job, keep=False):
2253
+ """Keep an unfinished run that is set aside in history/; keep: copy it (some of its answers stay)."""
2254
+ partial = job.out_path + ".partial.jsonl"
2255
+ hist = os.path.join(os.path.dirname(job.out_path), "history")
2256
+ os.makedirs(hist, exist_ok=True)
2257
+ stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(partial)))
2258
+ (shutil.copy2 if keep else os.replace)(partial, os.path.join(hist, f"{job.pack.name}-{stamp}.partial.jsonl"))
2259
+
2260
+ @staticmethod
2261
+ def _redo_ids(job):
2262
+ """The tests whose earlier answers a retest replaces: the picked ones, or all of them."""
2263
+ return {t["id"] for t in job.tests} if job.redo else {t["id"] for t in job.pack.tests}
2264
+
2227
2265
  def _archive(self, job, keep=False):
2228
2266
  """Keep a result file that is about to be replaced (rerun or outdated) in history/; keep:
2229
- copy it (some of its answers stay in the new file)."""
2230
- if job.tier == "smoke" or not os.path.isfile(job.out_path):
2267
+ copy it (some of its answers stay in the new file). Smoke results too: nothing is deleted."""
2268
+ if not os.path.isfile(job.out_path):
2231
2269
  return
2232
2270
  hist = os.path.join(os.path.dirname(job.out_path), "history")
2233
2271
  os.makedirs(hist, exist_ok=True)
@@ -11,6 +11,7 @@
11
11
  tuieval run --only my-model --packs my-pack,other-pack
12
12
  tuieval run --tags moe --packs my-pack # models tagged "moe"
13
13
  tuieval run --packs my-pack --tests a,b # only these tests of a pack (or --tests my-pack:a,other:b)
14
+ tuieval run --only my-model --packs my-pack --tests a --retest # ask a question again
14
15
  tuieval run --preset nightly # a selection saved in the TUI (presets.toml)
15
16
  tuieval run --tier smoke --only my-model # 3 tests per pack, 1 repeat: check paths and flags
16
17
  tuieval run --tier smoke --only openrouter:qwen/qwen3-32b # any OpenRouter model, no models.toml edit
@@ -742,7 +743,8 @@ def main(argv=None):
742
743
  p.add_argument("--packs", help="comma-separated packs (default: all)")
743
744
  p.add_argument("--tests", help="comma-separated test ids to run instead of the tier's sample: id with one "
744
745
  "pack in --packs, else pack:id (and --packs defaults to those packs)")
745
- p.add_argument("--force", action="store_true", help="rerun packs (or the --tests) that already have current results")
746
+ p.add_argument("--retest", "--force", action="store_true", dest="force",
747
+ help="ask again even if already answered: the packs, or just the --tests (earlier answers go to history/)")
746
748
  p.add_argument("--repeat", type=int, help="times each question is asked, any tier (default: 1 for smoke and "
747
749
  "screen, the pack's certification count for certify)")
748
750
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
@@ -821,6 +823,10 @@ def main(argv=None):
821
823
  say(f"{j.key}: skipping ({j.note})")
822
824
  elif j.status == "done":
823
825
  say(f"{j.key}: done earlier ({j.passed}/{j.done} passed)")
826
+ elif j.fresh and any(e.set_aside(j)):
827
+ finished, unfinished = e.set_aside(j)
828
+ what = " and ".join(f"{n} {w}" for n, w in ((finished, "finished"), (unfinished, "unfinished")) if n)
829
+ say(f"{j.key}: retest from zero; its {what} answers move to history/")
824
830
 
825
831
  def stop(*_):
826
832
  say("stopping…", RED)
@@ -385,6 +385,11 @@ HELP = {
385
385
  [b]Models at a time[/b]: how many of the ticked models run side by side (blank: this machine's
386
386
  parallel_models in models.toml, default 1). Answers note the models they ran alongside.
387
387
 
388
+ [b]Retest[/b]: ask the ticked questions again even if they're already answered (only the tests
389
+ picked with e, if any). Without it, answered questions are skipped. Before anything moves, Start lists
390
+ the packs with earlier answers and lets you choose for each: Zero (ask everything again), Continue
391
+ (keep what's answered, ask the rest) or Leave out. Replaced answers go to history/, never deleted.
392
+
388
393
  [b]Keys[/b]
389
394
  s start (with a run going: add to it, or queue it) r results
390
395
  a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
@@ -646,7 +651,7 @@ class SetupScreen(Screen):
646
651
  yield Input("", id="repeat", type="integer", max_length=2, placeholder="1")
647
652
  yield Label("Models at a time")
648
653
  yield Input("", id="parallel", type="integer", max_length=1, placeholder="1")
649
- yield Checkbox("Rerun existing results", id="force")
654
+ yield Checkbox("Retest (ask again, even if already answered)", id="force")
650
655
  yield Static(id="estimate")
651
656
  with Horizontal(id="buttons"):
652
657
  yield Button("Start [s]", id="start", variant="success")
@@ -958,7 +963,9 @@ class SetupScreen(Screen):
958
963
  if not run:
959
964
  text += "\n[yellow]Nothing selected can run (see skipping).[/yellow]"
960
965
  elif rerun:
961
- text += "\n[yellow]Everything selected has results; Start runs it again.[/yellow]"
966
+ text += "\n[yellow]Everything selected has results; Start asks which packs to ask again.[/yellow]"
967
+ if force:
968
+ text += "\n[yellow]Retest: Start lists the packs that would start from zero and lets you choose.[/yellow]"
962
969
  resumed = [j for j in run if "done earlier" in j.note]
963
970
  if resumed:
964
971
  text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
@@ -1037,32 +1044,32 @@ class SetupScreen(Screen):
1037
1044
  picks = self.current_picks(suites)
1038
1045
  sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1039
1046
  "parallel": self.parallel()}
1040
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
1041
- rerun = False
1047
+ e = self.app.engine
1048
+ jobs = e.plan(labels, suites, repeat, tier, True, picks)
1042
1049
  if not any(j.status == "waiting" for j in jobs):
1043
- # Everything selected already has results: Start means run it again (earlier results
1044
- # are kept in history/). Packs skipped for another reason (no vision, …) stay skipped.
1045
- jobs = self.app.engine.plan(labels, suites, repeat, tier, True, picks)
1046
- if not any(j.status == "waiting" for j in jobs):
1047
- reasons = sorted({j.note for j in jobs if j.status == "skipped"})
1048
- self.notify(f"Nothing selected can run: {'; '.join(reasons)}", severity="warning")
1049
- return
1050
- rerun = True
1051
- self.app.save_state(sel)
1052
- lost = [(j, what, n, why) for j in jobs if j.status == "waiting" for what, n, why in j.discards]
1053
- item = QueuedRun(sel, rerun, {(j.key, what) for j, what, _, _ in lost}, jobs, self.app.engine)
1054
- if not lost:
1055
- self.app.start_or_queue(item)
1050
+ reasons = sorted({j.note for j in jobs if j.status == "skipped"})
1051
+ self.notify(f"Nothing selected can run: {'; '.join(reasons)}", severity="warning")
1056
1052
  return
1053
+ self.app.save_state(sel)
1054
+ # Packs whose earlier answers a start would set aside (Retest, everything already done, or
1055
+ # other questions or settings): the user picks per pack before anything moves.
1056
+ rows, rerun = retest_rows(e, labels, suites, repeat, tier, picks, force)
1057
1057
 
1058
- def answer(yes):
1059
- if yes:
1060
- self.app.start_or_queue(item)
1061
- lines = "\n".join(f"• {j.label} · {j.pack.label}: {n} {what} answers — {why}" for j, what, n, why in lost)
1062
- self.app.push_screen(ConfirmScreen(
1063
- "These packs would start over: their earlier answers ran with other questions or settings.\n\n"
1064
- f"{lines}\n\nThe earlier answers move to results/<model>/history/ (kept, but no longer counted). "
1065
- "To resume instead, pick No and put the setting back in models.toml.\n\nStart over?"), answer)
1058
+ def go(choices):
1059
+ if choices is None:
1060
+ return
1061
+ item = QueuedRun(sel, {k for k, c in choices.items() if c == "zero"},
1062
+ {k for k, c in choices.items() if c == "leave"},
1063
+ {(r.key, what) for r in rows if choices.get(r.key) == "zero" for what, _, _ in r.base.discards},
1064
+ e)
1065
+ if not item.jobs:
1066
+ self.notify(f"Nothing to run: {item.problem}.", severity="warning")
1067
+ return
1068
+ self.app.start_or_queue(item)
1069
+ if rows:
1070
+ self.app.push_screen(RetestScreen(rows, force, rerun), go)
1071
+ else:
1072
+ go({})
1066
1073
 
1067
1074
  def action_tune(self):
1068
1075
  e = self.app.engine
@@ -1537,14 +1544,16 @@ def run_title(tier, models, packs):
1537
1544
 
1538
1545
  class QueuedRun:
1539
1546
  """A run waiting its turn. It's planned again when it starts, so it picks up what the runs before
1540
- it finished; it never sets aside answers that weren't confirmed when it was queued."""
1547
+ it finished; it never sets aside answers that weren't confirmed when it was queued. retest: the
1548
+ job keys to ask again from zero; leave: the ones left out."""
1541
1549
  kind = "Run"
1542
1550
 
1543
- def __init__(self, sel, rerun, accepted, jobs, eng):
1544
- self.sel, self.rerun, self.accepted = sel, rerun, accepted
1551
+ def __init__(self, sel, retest, leave, accepted, eng):
1552
+ self.sel, self.retest, self.leave, self.accepted = sel, retest, leave, accepted
1545
1553
  self.queued = time.time()
1554
+ self.jobs, self.problem = self.plan(eng)
1555
+ jobs = self.jobs or []
1546
1556
  self.title = run_title(sel["tier"], {j.label for j in jobs}, {j.pack.label for j in jobs})
1547
- self.jobs = jobs
1548
1557
  self.est = eng.estimate_seconds(jobs)[0]
1549
1558
 
1550
1559
  def tests(self, eng):
@@ -1563,11 +1572,11 @@ class QueuedRun:
1563
1572
  def plan(self, eng):
1564
1573
  """(jobs, None), or (None, why it can't run) now."""
1565
1574
  sel = self.sel
1566
- jobs = eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], sel["force"] or self.rerun,
1567
- self.tests(eng))
1575
+ jobs = [j for j in eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], self.retest,
1576
+ self.tests(eng)) if j.key not in self.leave]
1568
1577
  waiting = [j for j in jobs if j.status == "waiting"]
1569
1578
  if not waiting:
1570
- return None, "nothing left to run (earlier runs finished it)"
1579
+ return None, "every pack left is already done"
1571
1580
  new = sorted({j.key for j in waiting for what, _, _ in j.discards if (j.key, what) not in self.accepted})
1572
1581
  if new:
1573
1582
  return None, (f"it would now start over {', '.join(new)}, which wasn't confirmed when it was queued. "
@@ -1618,6 +1627,140 @@ class ChoiceScreen(ModalScreen):
1618
1627
  self.dismiss(None)
1619
1628
 
1620
1629
 
1630
+ class RetestRow:
1631
+ """A pack whose earlier answers a start could set aside. base: its job resuming; zero: from zero."""
1632
+
1633
+ def __init__(self, base, zero, finished, unfinished, choices, choice):
1634
+ self.base, self.zero, self.finished, self.unfinished = base, zero, finished, unfinished
1635
+ self.choices, self.choice = choices, choice
1636
+ self.key = base.key
1637
+
1638
+ def earlier(self):
1639
+ if self.base.discards:
1640
+ return ", ".join(f"{n} {what}" for what, n, _ in self.base.discards)
1641
+ parts = [f"{self.finished} finished"] * bool(self.finished) + \
1642
+ [f"[yellow]{self.unfinished} unfinished[/yellow]"] * bool(self.unfinished)
1643
+ return ", ".join(parts)
1644
+
1645
+ def happens(self):
1646
+ b = self.base
1647
+ if self.choice == "leave":
1648
+ return "not run now"
1649
+ if self.choice == "continue":
1650
+ return "skipped: already done" if b.status == "done" else f"keeps {b.done}, asks the other {b.total - b.done}"
1651
+ if b.discards:
1652
+ why = "; ".join(sorted({why for _, _, why in b.discards}))
1653
+ return f"[yellow]{why}[/yellow]; {sum(n for _, n, _ in b.discards)} → history/"
1654
+ what = "picked tests asked again" if self.zero.redo else "asked again"
1655
+ return f"{what}; {self.finished + self.unfinished} → history/"
1656
+
1657
+
1658
+ CHOICE_TEXT = {"zero": "[b red]Zero[/b red]", "continue": "[b green]Continue[/b green]", "leave": "[dim]Leave out[/dim]"}
1659
+
1660
+
1661
+ def retest_rows(eng, labels, packs, repeat, tier, picks, force):
1662
+ """(rows, rerun): the packs Start would set earlier answers aside for, or could. rerun: Retest is
1663
+ off but everything selected is done, so Start runs it again. A pack whose questions or settings
1664
+ changed can't continue (Zero or Leave out); with Retest every pack with answers starts at Zero,
1665
+ without it a pack that can resume continues."""
1666
+ base = eng.plan(labels, packs, repeat, tier, False, picks)
1667
+ zero = {j.key: j for j in eng.plan(labels, packs, repeat, tier, True, picks)}
1668
+ rerun = not force and not any(j.status == "waiting" for j in base)
1669
+ rows = []
1670
+ for b in base:
1671
+ z = zero[b.key]
1672
+ if z.status != "waiting":
1673
+ continue
1674
+ finished, unfinished = eng.set_aside(z)
1675
+ if b.discards:
1676
+ rows.append(RetestRow(b, z, finished, unfinished, ["zero", "leave"], "zero"))
1677
+ elif (force or rerun) and (finished or unfinished):
1678
+ rows.append(RetestRow(b, z, finished, unfinished, ["zero", "continue"],
1679
+ "zero" if force or b.status == "done" else "continue"))
1680
+ return rows, rerun
1681
+
1682
+
1683
+ class RetestScreen(ModalScreen):
1684
+ """Start's confirm when earlier answers would be set aside: a choice per model · pack. Dismisses
1685
+ with {job key: "zero" | "continue" | "leave"}, or None on cancel."""
1686
+ BINDINGS = [Binding("space", "toggle", "Toggle"), Binding("a", "all('zero')", "All from zero"),
1687
+ Binding("c", "all('continue')", "All continue"), Binding("escape", "cancel", "Cancel")]
1688
+
1689
+ def __init__(self, rows, force, rerun):
1690
+ super().__init__()
1691
+ self.rows, self.force, self.rerun = rows, force, rerun
1692
+
1693
+ def compose(self) -> ComposeResult:
1694
+ if self.force:
1695
+ title = ("[b]Retest: choose what each pack does.[/b] Zero asks every question again; Continue keeps "
1696
+ "what's answered and asks the rest.")
1697
+ elif self.rerun:
1698
+ title = ("[b]Everything selected has results.[/b] Choose which packs to ask again (Zero) and which "
1699
+ "to keep (Continue).")
1700
+ else:
1701
+ title = ("[b]These packs can't continue:[/b] their earlier answers ran with other questions or "
1702
+ "settings. Start them from zero, or leave them out (put the setting back in models.toml "
1703
+ "to resume).")
1704
+ with Vertical(id="dialog", classes="wide"):
1705
+ yield Static(title)
1706
+ yield DataTable(id="choices", cursor_type="row", zebra_stripes=True)
1707
+ yield Static("[dim]space toggles the highlighted row · a all from zero · c all continue · "
1708
+ "Zero: earlier answers go to results/<model>/history/ (kept, not counted)[/dim]")
1709
+ with Horizontal(classes="dialog-buttons"):
1710
+ yield Button(self.start_label(), id="start", variant="primary")
1711
+ yield Button("Cancel", id="cancel")
1712
+
1713
+ def on_mount(self):
1714
+ t = self.query_one(DataTable)
1715
+ t.add_column("Choice", key="choice", width=10)
1716
+ t.add_column("Model", key="model")
1717
+ t.add_column("Pack", key="pack")
1718
+ t.add_column("Earlier answers", key="earlier")
1719
+ t.add_column("What happens", key="happens")
1720
+ for r in self.rows:
1721
+ t.add_row(CHOICE_TEXT[r.choice], r.base.label, r.base.pack.label, r.earlier(), r.happens(), key=r.key)
1722
+ self.update_start()
1723
+ t.focus()
1724
+
1725
+ def redraw(self, r):
1726
+ t = self.query_one(DataTable)
1727
+ t.update_cell(r.key, "choice", CHOICE_TEXT[r.choice])
1728
+ t.update_cell(r.key, "happens", r.happens(), update_width=True)
1729
+ self.update_start()
1730
+
1731
+ def start_label(self):
1732
+ n = collections.Counter(r.choice for r in self.rows)
1733
+ parts = [f"{n[c]} {w}" for c, w in (("zero", "from zero"), ("continue", "continue"), ("leave", "left out"))
1734
+ if n[c]]
1735
+ return f"Start ({', '.join(parts)})"
1736
+
1737
+ def update_start(self):
1738
+ button = self.query_one("#start", Button)
1739
+ button.label = self.start_label()
1740
+ button.refresh(layout=True)
1741
+
1742
+ def action_toggle(self):
1743
+ t = self.query_one(DataTable)
1744
+ if not self.rows:
1745
+ return
1746
+ r = self.rows[t.cursor_row]
1747
+ r.choice = r.choices[(r.choices.index(r.choice) + 1) % len(r.choices)]
1748
+ self.redraw(r)
1749
+
1750
+ def action_all(self, choice):
1751
+ for r in self.rows:
1752
+ if choice in r.choices:
1753
+ r.choice = choice
1754
+ self.redraw(r)
1755
+
1756
+ def action_cancel(self):
1757
+ self.dismiss(None)
1758
+
1759
+ @on(Button.Pressed)
1760
+ def answer(self, event):
1761
+ self.dismiss({r.key: r.choice for r in self.rows} if event.button.id == "start" else None)
1762
+
1763
+
1621
1764
  class ConfirmScreen(ModalScreen):
1622
1765
  def __init__(self, question):
1623
1766
  super().__init__()
@@ -2952,7 +3095,8 @@ class EvalsApp(App):
2952
3095
  #reasoning { color: $text-muted; }
2953
3096
  StreamView { height: 1fr; border: none; padding: 0; }
2954
3097
  #tabs { height: 12; }
2955
- AddModelScreen, ConfirmScreen { align: center middle; }
3098
+ AddModelScreen, ConfirmScreen, RetestScreen { align: center middle; }
3099
+ RetestScreen #start { width: auto; }
2956
3100
  SelectionList > .selection-list--button, SelectionList > .selection-list--button-highlighted {
2957
3101
  color: $panel; background: $panel;
2958
3102
  }
@@ -3091,8 +3235,6 @@ class EvalsApp(App):
3091
3235
  if problem:
3092
3236
  self.notify(f"Can't start {item.title}: {problem}", severity="warning", timeout=15)
3093
3237
  return
3094
- if item.kind == "Run" and item.rerun:
3095
- self.notify("Everything selected had results; running it again.")
3096
3238
  self.add_session(screen)
3097
3239
  return
3098
3240
  run = self.active_session
@@ -380,6 +380,9 @@ class PickTests(unittest.TestCase):
380
380
  hist = os.listdir(os.path.join(self.ws, "results", "m", "history"))
381
381
  self.assertTrue(any(f.startswith("apps-") for f in hist), hist)
382
382
  self.assertEqual(self.status(), "certified")
383
+ self.server("oracle")
384
+ tuieval(self.ws, "run", "--tests", "apps:b", "--retest") # --retest = --force
385
+ self.assertEqual(self.rows(), {"a": True, "b": True, "c": True, "d": True})
383
386
 
384
387
  def test_cli_errors(self):
385
388
  self.server("oracle")
@@ -408,6 +411,160 @@ class PickTests(unittest.TestCase):
408
411
  self.assertEqual(len(self.rows("other")), 1) # no pick: the Screen sample (screen = 1)
409
412
 
410
413
 
414
+ class Retest(unittest.TestCase):
415
+ """Retest starts from zero (an unfinished run's answers too), keeps every replaced answer in
416
+ history/ (smoke too), and the TUI asks per pack before anything moves."""
417
+
418
+ def setUp(self):
419
+ self.tmp = tempfile.TemporaryDirectory()
420
+ self.ws = os.path.join(self.tmp.name, "ws")
421
+ tuieval(self.tmp.name, "init", self.ws)
422
+ for name in ("apps", "other"):
423
+ d = os.path.join(self.ws, "packs", name)
424
+ os.makedirs(d)
425
+ write(os.path.join(d, "pack.toml"), f'label = "{name}"\nscreen = 4\n[certify]\nrepeat = 1\n'
426
+ '[gate]\nmin_accuracy = 0.1\n')
427
+ write(os.path.join(d, "tests.yaml"), "".join(
428
+ f"- {{id: {t}, input: {name} question {t}, expected: 1, reference: 'ANSWER: 1', wrong: ['ANSWER: 2'], "
429
+ "difficulty: easy}\n" for t in "abcd"))
430
+ self.port = free_port()
431
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
432
+ f.write(f'\n[servers.m]\nurl = "http://127.0.0.1:{self.port}"\n')
433
+ self.mock = Mock(os.path.join(self.ws, "packs"), "oracle", port=self.port)
434
+ tuieval(self.ws, "add", "mock", "--server", "m", "--label", "m")
435
+ tuieval(self.ws, "run", "--tier", "certify")
436
+ self.mock.stop()
437
+ self.mock = Mock(os.path.join(self.ws, "packs"), "wrong", port=self.port) # retested answers fail
438
+
439
+ def tearDown(self):
440
+ self.mock.stop()
441
+ self.tmp.cleanup()
442
+
443
+ def path(self, pack, smoke=False):
444
+ return os.path.join(self.ws, "results", *(["smoke"] if smoke else []), "m", f"{pack}.json")
445
+
446
+ def passes(self, pack):
447
+ return {r["test"]: r["pass"] for r in json.loads(read(self.path(pack)))["results"]}
448
+
449
+ def unfinished(self, pack, tests):
450
+ """Turn a pack's finished answers for these tests into an unfinished run of the same settings."""
451
+ from tuieval import engine
452
+ e = engine.Engine(root=self.ws)
453
+ j = e.plan(["m"], [pack], None, "certify", False, {})[0]
454
+ rows = [r for r in json.loads(read(j.out_path))["results"] if r["test"] in tests]
455
+ write(j.out_path + ".partial.jsonl", "".join(json.dumps(l) + "\n" for l in [e._partial_header(j)] + rows))
456
+ os.remove(j.out_path)
457
+
458
+ def history(self, smoke=False):
459
+ d = os.path.join(self.ws, "results", *(["smoke"] if smoke else []), "m", "history")
460
+ return sorted(os.listdir(d)) if os.path.isdir(d) else []
461
+
462
+ def test_cli_retest_starts_from_zero(self):
463
+ self.unfinished("apps", "ab")
464
+ out = tuieval(self.ws, "run", "--tier", "certify", "--packs", "apps", "--retest").stdout
465
+ self.assertIn("m/apps: retest from zero; its 2 unfinished answers move to history/", out)
466
+ self.assertNotIn("resuming", out)
467
+ self.assertEqual(self.passes("apps"), dict.fromkeys("abcd", False)) # all asked again
468
+ self.assertTrue(any(f.endswith(".partial.jsonl") for f in self.history()))
469
+
470
+ def test_picked_retest_keeps_the_rest_of_an_unfinished_run(self):
471
+ self.unfinished("apps", "abc")
472
+ tuieval(self.ws, "run", "--tier", "certify", "--tests", "apps:b", "--retest")
473
+ partial = self.path("apps") + ".partial.jsonl"
474
+ self.assertFalse(os.path.exists(partial))
475
+ self.assertEqual(self.passes("apps"), {"a": True, "b": False, "c": True}) # only b asked again
476
+ self.assertTrue(any(f.endswith(".partial.jsonl") for f in self.history())) # a copy, b included
477
+
478
+ def test_smoke_retest_keeps_history(self):
479
+ tuieval(self.ws, "run", "--tier", "smoke", "--packs", "apps")
480
+ tuieval(self.ws, "run", "--tier", "smoke", "--packs", "apps", "--retest")
481
+ self.assertEqual(len(self.history(smoke=True)), 1)
482
+ self.assertEqual(tuieval(self.ws, "list").returncode, 0)
483
+
484
+ def tui(self, steps):
485
+ """Drive Setup with apps and other ticked for model m (Certify); steps runs against the screen."""
486
+ code = textwrap.dedent("""
487
+ import asyncio, json, time
488
+ from tuieval.tui import EvalsApp, RetestScreen, RunScreen
489
+ from textual.widgets import SelectionList, Checkbox
490
+ async def go():
491
+ app = EvalsApp({})
492
+ async with app.run_test(size=(160, 50)) as pilot:
493
+ await pilot.pause()
494
+ s = app.screen
495
+ for p in ("apps", "other"):
496
+ s.query_one("#suites", SelectionList).select(p)
497
+ s.selected_models = {"m"}
498
+ s.refresh_models()
499
+ s.query_one("#tier-certify").value = True
500
+ out = {}
501
+ %s
502
+ deadline = time.time() + 120
503
+ while time.time() < deadline and (app.queue or (isinstance(app.active_session, RunScreen)
504
+ and app.active_session.running)):
505
+ await pilot.pause(0.2)
506
+ out["sessions"] = len(app.sessions)
507
+ print(json.dumps(out))
508
+ asyncio.run(go())
509
+ """) % textwrap.indent(textwrap.dedent(steps), " " * 8)
510
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
511
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
512
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
513
+ self.assertIsInstance(out, dict, out)
514
+ return out
515
+
516
+ def test_tui_asks_per_pack(self):
517
+ self.unfinished("other", "ab")
518
+ out = self.tui("""
519
+ s.query_one("#force", Checkbox).value = True
520
+ await pilot.pause()
521
+ s.action_start()
522
+ await pilot.pause()
523
+ d = app.screen
524
+ out["asked"] = isinstance(d, RetestScreen)
525
+ out["rows"] = [(r.key, r.choice, r.finished, r.unfinished) for r in d.rows]
526
+ await pilot.press("down", "space") # other: Continue
527
+ out["button"] = str(d.query_one("#start").label)
528
+ await pilot.press("escape") # cancel: nothing runs
529
+ await pilot.pause()
530
+ out["after_cancel"] = len(app.sessions)
531
+ s.action_start()
532
+ await pilot.pause()
533
+ await pilot.press("down", "space")
534
+ await pilot.pause()
535
+ app.screen.query_one("#start").press()
536
+ await pilot.pause()
537
+ """)
538
+ self.assertTrue(out["asked"])
539
+ self.assertEqual(out["rows"], [["m/apps", "zero", 4, 0], ["m/other", "zero", 0, 2]])
540
+ self.assertEqual(out["button"], "Start (1 from zero, 1 continue)")
541
+ self.assertEqual(out["after_cancel"], 0)
542
+ self.assertEqual(out["sessions"], 1)
543
+ self.assertEqual(self.passes("apps"), dict.fromkeys("abcd", False)) # from zero
544
+ self.assertEqual(self.passes("other"), {"a": True, "b": True, "c": False, "d": False}) # continued
545
+
546
+ def test_tui_everything_done_or_settings_changed(self):
547
+ models = os.path.join(self.ws, "models.toml")
548
+ write(models, read(models).replace("temperature = 0.7", "temperature = 0.6"))
549
+ out = self.tui("""
550
+ s.query_one("#suites", SelectionList).deselect("other")
551
+ await pilot.pause()
552
+ s.action_start()
553
+ await pilot.pause()
554
+ d = app.screen
555
+ out["rows"] = [(r.key, r.choices, r.choice) for r in d.rows]
556
+ await pilot.press("space") # Leave out
557
+ await pilot.pause()
558
+ out["happens"] = d.rows[0].happens()
559
+ d.query_one("#start").press()
560
+ await pilot.pause()
561
+ """)
562
+ self.assertEqual(out["rows"], [["m/apps", ["zero", "leave"], "zero"]])
563
+ self.assertEqual(out["happens"], "not run now")
564
+ self.assertEqual(out["sessions"], 0) # nothing ran
565
+ self.assertEqual(self.passes("apps"), dict.fromkeys("abcd", True))
566
+
567
+
411
568
  class Parallel(unittest.TestCase):
412
569
  """Several models at a time (parallel_models / --parallel): each runs in its own lane on its own
413
570
  port, answers note the models they ran alongside, and the scheduler keeps the memory and
File without changes
File without changes
File without changes