tuieval 0.2.0.dev72__tar.gz → 0.2.0.dev75__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.dev72 → tuieval-0.2.0.dev75}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/_version.py +2 -2
  4. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/engine.py +48 -10
  5. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/run_evals.py +4 -0
  6. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/tui.py +181 -41
  7. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/tests/test_tuieval.py +204 -0
  8. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/README.md +0 -0
  11. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/RELEASING.md +0 -0
  12. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/README.md +0 -0
  13. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/favicon.ico +0 -0
  14. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/readme-header.png +0 -0
  15. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/social-preview.png +0 -0
  16. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-1024.png +0 -0
  17. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-128.png +0 -0
  18. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-16.png +0 -0
  19. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-16.svg +0 -0
  20. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-256.png +0 -0
  21. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-32.png +0 -0
  22. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-48.png +0 -0
  23. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-512.png +0 -0
  24. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-64.png +0 -0
  25. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  26. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/brand/tuieval-icon.svg +0 -0
  27. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/tui-answer-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/tui-run-reasoning.png +0 -0
  29. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/images/tui-setup.png +0 -0
  30. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/models.md +0 -0
  31. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/docs/writing-packs.md +0 -0
  32. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/pyproject.toml +0 -0
  33. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/__init__.py +0 -0
  34. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/__main__.py +0 -0
  35. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/builtin.toml +0 -0
  36. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/cli.py +0 -0
  37. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/client.py +0 -0
  38. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/compare.py +0 -0
  39. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/doctor.py +0 -0
  40. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/export.py +0 -0
  41. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/graders/__init__.py +0 -0
  42. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/graders/answer.py +0 -0
  43. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/graders/code.py +0 -0
  44. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/graders/rag.py +0 -0
  45. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/graders/reply.py +0 -0
  46. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/graders/tool_call.py +0 -0
  47. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/scaffold.py +0 -0
  55. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/selftest.py +0 -0
  56. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/models.toml +0 -0
  57. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  58. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/answer/system.txt +0 -0
  59. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  60. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/code/pack.toml +0 -0
  61. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/code/system.txt +0 -0
  62. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  63. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  64. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/rag/system.txt +0 -0
  65. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  66. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  67. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/reply/system.txt +0 -0
  68. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  69. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  70. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  71. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  72. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  73. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/tune.py +0 -0
  74. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/verdict.py +0 -0
  75. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev72 → tuieval-0.2.0.dev75}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Fix: a question description or grader reason containing `[` no longer crashes the run screen.
6
+ - 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/.
5
7
  - 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).
6
8
  - `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.
7
9
  - 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev72
3
+ Version: 0.2.0.dev75
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.dev72'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev72')
21
+ __version__ = version = '0.2.0.dev75'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev75')
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)
@@ -823,6 +823,10 @@ def main(argv=None):
823
823
  say(f"{j.key}: skipping ({j.note})")
824
824
  elif j.status == "done":
825
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/")
826
830
 
827
831
  def stop(*_):
828
832
  say("stopping…", RED)
@@ -386,7 +386,9 @@ HELP = {
386
386
  parallel_models in models.toml, default 1). Answers note the models they ran alongside.
387
387
 
388
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. Replaced answers go to history/.
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.
390
392
 
391
393
  [b]Keys[/b]
392
394
  s start (with a run going: add to it, or queue it) r results
@@ -961,7 +963,9 @@ class SetupScreen(Screen):
961
963
  if not run:
962
964
  text += "\n[yellow]Nothing selected can run (see skipping).[/yellow]"
963
965
  elif rerun:
964
- text += "\n[yellow]Everything selected has results; Start retests it.[/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]"
965
969
  resumed = [j for j in run if "done earlier" in j.note]
966
970
  if resumed:
967
971
  text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
@@ -1040,32 +1044,32 @@ class SetupScreen(Screen):
1040
1044
  picks = self.current_picks(suites)
1041
1045
  sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1042
1046
  "parallel": self.parallel()}
1043
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
1044
- rerun = False
1047
+ e = self.app.engine
1048
+ jobs = e.plan(labels, suites, repeat, tier, True, picks)
1045
1049
  if not any(j.status == "waiting" for j in jobs):
1046
- # Everything selected already has results: Start means run it again (earlier results
1047
- # are kept in history/). Packs skipped for another reason (no vision, …) stay skipped.
1048
- jobs = self.app.engine.plan(labels, suites, repeat, tier, True, picks)
1049
- if not any(j.status == "waiting" for j in jobs):
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")
1052
- return
1053
- rerun = True
1054
- self.app.save_state(sel)
1055
- lost = [(j, what, n, why) for j in jobs if j.status == "waiting" for what, n, why in j.discards]
1056
- item = QueuedRun(sel, rerun, {(j.key, what) for j, what, _, _ in lost}, jobs, self.app.engine)
1057
- if not lost:
1058
- 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")
1059
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)
1060
1057
 
1061
- def answer(yes):
1062
- if yes:
1063
- self.app.start_or_queue(item)
1064
- lines = "\n".join(f"• {j.label} · {j.pack.label}: {n} {what} answers — {why}" for j, what, n, why in lost)
1065
- self.app.push_screen(ConfirmScreen(
1066
- "These packs would start over: their earlier answers ran with other questions or settings.\n\n"
1067
- f"{lines}\n\nThe earlier answers move to results/<model>/history/ (kept, but no longer counted). "
1068
- "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({})
1069
1073
 
1070
1074
  def action_tune(self):
1071
1075
  e = self.app.engine
@@ -1540,14 +1544,16 @@ def run_title(tier, models, packs):
1540
1544
 
1541
1545
  class QueuedRun:
1542
1546
  """A run waiting its turn. It's planned again when it starts, so it picks up what the runs before
1543
- 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."""
1544
1549
  kind = "Run"
1545
1550
 
1546
- def __init__(self, sel, rerun, accepted, jobs, eng):
1547
- 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
1548
1553
  self.queued = time.time()
1554
+ self.jobs, self.problem = self.plan(eng)
1555
+ jobs = self.jobs or []
1549
1556
  self.title = run_title(sel["tier"], {j.label for j in jobs}, {j.pack.label for j in jobs})
1550
- self.jobs = jobs
1551
1557
  self.est = eng.estimate_seconds(jobs)[0]
1552
1558
 
1553
1559
  def tests(self, eng):
@@ -1566,11 +1572,11 @@ class QueuedRun:
1566
1572
  def plan(self, eng):
1567
1573
  """(jobs, None), or (None, why it can't run) now."""
1568
1574
  sel = self.sel
1569
- jobs = eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], sel["force"] or self.rerun,
1570
- 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]
1571
1577
  waiting = [j for j in jobs if j.status == "waiting"]
1572
1578
  if not waiting:
1573
- return None, "nothing left to run (earlier runs finished it)"
1579
+ return None, "every pack left is already done"
1574
1580
  new = sorted({j.key for j in waiting for what, _, _ in j.discards if (j.key, what) not in self.accepted})
1575
1581
  if new:
1576
1582
  return None, (f"it would now start over {', '.join(new)}, which wasn't confirmed when it was queued. "
@@ -1621,6 +1627,140 @@ class ChoiceScreen(ModalScreen):
1621
1627
  self.dismiss(None)
1622
1628
 
1623
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
+
1624
1764
  class ConfirmScreen(ModalScreen):
1625
1765
  def __init__(self, question):
1626
1766
  super().__init__()
@@ -2099,9 +2239,10 @@ class RunScreen(Screen):
2099
2239
  self.req_started, self.req_tokens, self.req_first = time.time(), 0, None
2100
2240
  job = d["job"]
2101
2241
  rep_note = f" · repeat {d['repeat'] + 1}/{job.repeat}" if job.repeat > 1 else ""
2102
- self.current = (f"[b]{job.label}[/b] · {job.pack.label} · test {job.done + 1}/{job.total}{rep_note}"
2242
+ self.current = (f"[b]{rich_escape(job.label)}[/b] · {rich_escape(job.pack.label)} · test "
2243
+ f"{job.done + 1}/{job.total}{rep_note}"
2103
2244
  f"{' [magenta]🖼 image[/magenta]' if d['has_image'] else ''}\n"
2104
- f"{diff_badge(d.get('difficulty', 'unrated'))} {d['test']}")
2245
+ f"{diff_badge(d.get('difficulty', 'unrated'))} {rich_escape(d['test'])}")
2105
2246
  elif kind == "request_done":
2106
2247
  if self.watched(kind, d):
2107
2248
  self.flush_streams()
@@ -2111,12 +2252,12 @@ class RunScreen(Screen):
2111
2252
  # follow new rows unless you're browsing an older one
2112
2253
  at_end = not recent.has_focus or recent.cursor_row >= recent.row_count - 1
2113
2254
  calls = f" [cyan]→ {r['tool_calls'][0]['name']}()[/cyan]" if r.get("tool_calls") else ""
2114
- row_key = recent.add_row(mark, diff_badge(r.get("difficulty", "unrated")), r["description"][:50], job.label,
2255
+ row_key = recent.add_row(mark, diff_badge(r.get("difficulty", "unrated")), rich_escape(r["description"][:50]), job.label,
2115
2256
  str(r.get("completion_tokens") or ""),
2116
2257
  (f"{r['ttft_s']:.1f}" + (" ⟲" if r.get("cached_tokens") else ""))
2117
2258
  if r.get("ttft_s") is not None else "",
2118
2259
  f"{r['gen_tps']:.0f}" if r.get("gen_tps") else "", f"{r['total_s']:.0f}",
2119
- r["reason"][:90] + calls
2260
+ rich_escape(r["reason"][:90]) + calls
2120
2261
  + (f" [red]copy of repeat {r['identical_to_repeat'] + 1}[/red]"
2121
2262
  if r.get("identical_to_repeat") is not None else ""))
2122
2263
  self.stats.append(r)
@@ -2224,7 +2365,7 @@ class RunScreen(Screen):
2224
2365
  self.event("[bold yellow]REUSE[/bold yellow] " + msg)
2225
2366
  self.notify(msg, severity="warning", timeout=20)
2226
2367
  elif kind == "identical_answer":
2227
- msg = (f"{d['label']} · {d['pack']} · {d['test']}: repeat {d['repeat'] + 1} is an exact copy of repeat "
2368
+ msg = (f"{d['label']} · {d['pack']} · {rich_escape(d['test'])}: repeat {d['repeat'] + 1} is an exact copy of repeat "
2228
2369
  f"{d['same'] + 1}, so the server may be returning cached answers rather than fresh ones.")
2229
2370
  self.event("[bold red]COPY[/bold red] " + msg)
2230
2371
  self.notify(msg, severity="error", timeout=20)
@@ -2571,7 +2712,7 @@ class ResultsScreen(Screen):
2571
2712
  # compare.failures lists the failed rows in order, so they line up with fail_rows
2572
2713
  self.fail_rows = [r for r in rows if not r["ok"]]
2573
2714
  for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
2574
- f.add_row(model, diff_badge(level), test, rich_escape(reason[:130]), key=str(i))
2715
+ f.add_row(model, diff_badge(level), rich_escape(test), rich_escape(reason[:130]), key=str(i))
2575
2716
  self.fill_per_question()
2576
2717
  self.fill_pta()
2577
2718
  self.fill_readiness(results_dir)
@@ -2955,7 +3096,8 @@ class EvalsApp(App):
2955
3096
  #reasoning { color: $text-muted; }
2956
3097
  StreamView { height: 1fr; border: none; padding: 0; }
2957
3098
  #tabs { height: 12; }
2958
- AddModelScreen, ConfirmScreen { align: center middle; }
3099
+ AddModelScreen, ConfirmScreen, RetestScreen { align: center middle; }
3100
+ RetestScreen #start { width: auto; }
2959
3101
  SelectionList > .selection-list--button, SelectionList > .selection-list--button-highlighted {
2960
3102
  color: $panel; background: $panel;
2961
3103
  }
@@ -3094,8 +3236,6 @@ class EvalsApp(App):
3094
3236
  if problem:
3095
3237
  self.notify(f"Can't start {item.title}: {problem}", severity="warning", timeout=15)
3096
3238
  return
3097
- if item.kind == "Run" and item.rerun:
3098
- self.notify("Everything selected had results; running it again.")
3099
3239
  self.add_session(screen)
3100
3240
  return
3101
3241
  run = self.active_session
@@ -411,6 +411,160 @@ class PickTests(unittest.TestCase):
411
411
  self.assertEqual(len(self.rows("other")), 1) # no pick: the Screen sample (screen = 1)
412
412
 
413
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
+
414
568
  class Parallel(unittest.TestCase):
415
569
  """Several models at a time (parallel_models / --parallel): each runs in its own lane on its own
416
570
  port, answers note the models they ran alongside, and the scheduler keeps the memory and
@@ -581,6 +735,56 @@ class Parallel(unittest.TestCase):
581
735
  self.assertEqual(sum(r["pass"] for r in self.results("b")["results"]), 6)
582
736
 
583
737
 
738
+ class MarkupInText(unittest.TestCase):
739
+ """Brackets in a description or a grader's reason are shown as text: an unmatched
740
+ "[" once crashed the run screen (it swallowed the "[dim]" of the line after it)."""
741
+
742
+ def test_run_screen_shows_brackets(self):
743
+ with tempfile.TemporaryDirectory() as tmp:
744
+ ws = os.path.join(tmp, "ws")
745
+ tuieval(tmp, "init", ws)
746
+ d = os.path.join(ws, "packs", "br")
747
+ os.makedirs(d)
748
+ write(os.path.join(d, "pack.toml"), 'label = "Brackets"\n[certify]\nrepeat = 1\n[gate]\nmin_accuracy = 0.1\n')
749
+ write(os.path.join(d, "tests.yaml"),
750
+ "- {id: a, description: 'notional in [t - 10 s, t), excluding ties', input: q a, expected: 1, "
751
+ "reference: 'ANSWER: 1', wrong: ['ANSWER: [2'], difficulty: hard}\n"
752
+ "- {id: b, description: 'window [a, b) then [/dim] text', input: q b, expected: 1, "
753
+ "reference: 'ANSWER: 1', wrong: ['ANSWER: [2'], difficulty: hard}\n")
754
+ mock = [sys.executable, os.path.join(HERE, "mock_server.py"), "--port", "{port}", "--packs",
755
+ os.path.join(ws, "packs"), "--model", "{served_name}", "--mode", "wrong", "--delay", "0.2"]
756
+ with open(os.path.join(ws, "models.toml"), "a") as f:
757
+ f.write(f'\n[servers.mk]\ncmd = {json.dumps(mock)}\nport = {free_port()}\n'
758
+ '\n[[models]]\nlabel = "m"\nserver = "mk"\nmodel = "m"\n')
759
+ code = textwrap.dedent("""
760
+ import asyncio, json, time
761
+ from tuieval.tui import EvalsApp, RunScreen
762
+ from textual.widgets import SelectionList
763
+ async def go():
764
+ app = EvalsApp({})
765
+ async with app.run_test(size=(160, 50)) as pilot:
766
+ await pilot.pause()
767
+ s = app.screen
768
+ s.query_one("#suites", SelectionList).select("br")
769
+ s.selected_models = {"m"}
770
+ s.refresh_models()
771
+ s.query_one("#tier-certify").value = True
772
+ await pilot.pause()
773
+ s.action_start()
774
+ deadline = time.time() + 240
775
+ while time.time() < deadline:
776
+ await pilot.pause(0.2)
777
+ if isinstance(app.screen, RunScreen) and not app.screen.running:
778
+ break
779
+ print(json.dumps([(j.key, j.status, j.done) for j in app.screen.jobs]))
780
+ asyncio.run(go())
781
+ """)
782
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=480,
783
+ env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""))
784
+ self.assertNotIn("MarkupError", p.stderr + p.stdout)
785
+ self.assertEqual(json.loads(p.stdout.strip().splitlines()[-1]), [["m/br", "done", 2]], p.stderr[-2000:])
786
+
787
+
584
788
  class AddToRun(unittest.TestCase):
585
789
  """More evals while a run is going: added to it (they run after its other jobs) or queued after it."""
586
790
 
File without changes
File without changes
File without changes