tuieval 0.2.0.dev100__tar.gz → 0.2.0.dev103__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 (79) hide show
  1. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/PKG-INFO +2 -2
  3. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/README.md +1 -1
  4. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/engine.py +32 -0
  6. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/tui.py +82 -10
  7. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/tests/test_tuieval.py +135 -0
  8. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/RELEASING.md +0 -0
  11. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/README.md +0 -0
  12. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/favicon.ico +0 -0
  13. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/readme-header.png +0 -0
  14. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/social-preview.png +0 -0
  15. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-1024.png +0 -0
  16. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-128.png +0 -0
  17. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-16.png +0 -0
  18. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-16.svg +0 -0
  19. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-256.png +0 -0
  20. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-32.png +0 -0
  21. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-48.png +0 -0
  22. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-512.png +0 -0
  23. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-64.png +0 -0
  24. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  25. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/brand/tuieval-icon.svg +0 -0
  26. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/tui-answer-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/tui-run-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/images/tui-setup.png +0 -0
  29. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/models.md +0 -0
  30. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/docs/writing-packs.md +0 -0
  31. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/pyproject.toml +0 -0
  32. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/__init__.py +0 -0
  33. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/__main__.py +0 -0
  34. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/builtin.toml +0 -0
  35. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/cli.py +0 -0
  36. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/client.py +0 -0
  37. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/compare.py +0 -0
  38. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/doctor.py +0 -0
  39. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/insights.py +0 -0
  47. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/run_evals.py +0 -0
  55. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/src/tuieval/yamlout.py +0 -0
  79. {tuieval-0.2.0.dev100 → tuieval-0.2.0.dev103}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Run screen: `x` removes the highlighted pack from the run going if it hasn't started (the reverse of adding it); a model left with nothing isn't started.
6
+ - `k` (skip model) asks first, naming the model. Yes/no dialogs start on No; `y` answers yes, `n` or Esc no.
5
7
  - Fix: hosted models no longer count as running alongside local ones (they don't share the machine), so a one-model-at-a-time setup no longer gets the "ran alongside other models" note. Older results are read the same way.
6
8
  - Results, Per question: the model summary shows every picked model without scrolling, and fits the window: shorter column names, and long model names cut in the middle (both ends kept). The PTA index table fits the same way.
7
9
  - PTA index: the accuracy bar shows answers cut off at max_tokens in orange, with `(n% truncated)` beside it and a **truncated** column in the table, so overthinking stands apart from wrong answers.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev100
3
+ Version: 0.2.0.dev103
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
@@ -143,7 +143,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
143
143
 
144
144
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
145
145
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
146
- 3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
146
+ 3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model (after a yes), `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run. `x` takes a pack that hasn't started out of the run; `w` lists queued runs (`del` removes one).
147
147
  4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures; click a column name to sort. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
148
148
 
149
149
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
@@ -117,7 +117,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
117
117
 
118
118
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
119
119
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
120
- 3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
120
+ 3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model (after a yes), `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run. `x` takes a pack that hasn't started out of the run; `w` lists queued runs (`del` removes one).
121
121
  4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures; click a column name to sort. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
122
122
 
123
123
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
@@ -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.dev100'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev100')
21
+ __version__ = version = '0.2.0.dev103'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev103')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -457,6 +457,7 @@ class Job:
457
457
  earlier: list = dataclasses.field(default_factory=list) # earlier sittings (see Engine._sittings)
458
458
  stop_early: bool = False # stop once the pack's FAIL is certain (verdict.fail_certain)
459
459
  first: list = dataclasses.field(default_factory=list) # test ids round 1 asks first (most failed)
460
+ claimed: bool = False # its model's server took it on: it can no longer be removed from the run
460
461
 
461
462
  @property
462
463
  def label(self):
@@ -1728,6 +1729,33 @@ class Engine:
1728
1729
  r["cond"].notify_all()
1729
1730
  return added, None if added else "everything picked is already in the run"
1730
1731
 
1732
+ def removable(self, job):
1733
+ """Why a job of the run going can't be removed (None: it can): only a pack not yet started."""
1734
+ if job.status in ("done", "failed", "skipped"):
1735
+ return "it has finished"
1736
+ if job.claimed or job.status == "running":
1737
+ return "it's running (k skips the model)"
1738
+ return None
1739
+
1740
+ def remove_jobs(self, keys):
1741
+ """Take jobs not yet started (removable) out of the run going, the reverse of add_jobs: they never
1742
+ run, and a model left with none isn't started. Returns the jobs removed."""
1743
+ removed = []
1744
+ with self._run_lock:
1745
+ r = self._run
1746
+ if not r or not r["open"]:
1747
+ return removed
1748
+ for j in [j for j in r["jobs"] if j.key in keys and self.removable(j) is None]:
1749
+ j.status, j.note = "removed", "removed from the run"
1750
+ r["jobs"].remove(j)
1751
+ for _, group in r["pending"]:
1752
+ if j in group:
1753
+ group.remove(j)
1754
+ removed.append(j)
1755
+ r["pending"][:] = [g for g in r["pending"] if g[1]] # the lanes hold this very list
1756
+ r["cond"].notify_all()
1757
+ return removed
1758
+
1731
1759
  def _run_lanes(self, parallel, parallel_hosted=4):
1732
1760
  """Run each model's jobs in its own lane: up to `parallel` local models at a time, in order (the
1733
1761
  next one starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
@@ -2021,6 +2049,10 @@ class Engine:
2021
2049
  failed_in_row = 0
2022
2050
  for j in jobs:
2023
2051
  self._check()
2052
+ with self._run_lock:
2053
+ if j.status == "removed": # taken out of the run while its model ran another pack
2054
+ continue
2055
+ j.claimed = True
2024
2056
  try:
2025
2057
  self._run_job(j, base_url, run_info, alive=info["alive"])
2026
2058
  except BaseException as ex:
@@ -1828,16 +1828,33 @@ class RetestScreen(ModalScreen):
1828
1828
 
1829
1829
 
1830
1830
  class ConfirmScreen(ModalScreen):
1831
- def __init__(self, question):
1831
+ """Yes or no; No has the focus, so Enter by reflex changes nothing. y: yes; n or Esc: no
1832
+ (and no_keys, e.g. the key that opened it, pressed again)."""
1833
+ BINDINGS = [Binding("y", "answer(True)", "Yes", show=False), Binding("n", "answer(False)", "No", show=False),
1834
+ Binding("escape", "answer(False)", "No", show=False)]
1835
+
1836
+ def __init__(self, question, no_keys=()):
1832
1837
  super().__init__()
1833
1838
  self.question = question
1839
+ self.no_keys = no_keys
1834
1840
 
1835
1841
  def compose(self) -> ComposeResult:
1836
1842
  with Vertical(id="dialog"):
1837
1843
  yield Label(self.question)
1838
1844
  with Horizontal(classes="dialog-buttons"):
1839
- yield Button("Yes", id="yes", variant="error")
1840
- yield Button("No", id="no")
1845
+ yield Button("Yes (y)", id="yes", variant="error")
1846
+ yield Button("No (n)", id="no")
1847
+
1848
+ def on_mount(self):
1849
+ self.query_one("#no", Button).focus()
1850
+
1851
+ def on_key(self, event):
1852
+ if event.key in self.no_keys:
1853
+ event.stop()
1854
+ self.dismiss(False)
1855
+
1856
+ def action_answer(self, yes):
1857
+ self.dismiss(yes)
1841
1858
 
1842
1859
  @on(Button.Pressed)
1843
1860
  def answer(self, event):
@@ -2114,6 +2131,7 @@ class RunScreen(Screen):
2114
2131
  # priority: these work whichever pane has focus (the text panes would otherwise swallow keys)
2115
2132
  BINDINGS = [
2116
2133
  Binding("k", "skip_model", "Skip model", priority=True),
2134
+ Binding("x", "remove_job", "Remove from run", priority=True),
2117
2135
  Binding("c", "cancel_all", "Cancel all", priority=True),
2118
2136
  Binding("f", "toggle_follow", "Pause scroll", priority=True),
2119
2137
  Binding("v", "watch_next", "Next model", priority=True),
@@ -2124,7 +2142,7 @@ class RunScreen(Screen):
2124
2142
  Binding("q", "back", "Back", show=False, priority=True),
2125
2143
  ]
2126
2144
  kind = "Run"
2127
- LIVE_ONLY = {"skip_model", "cancel_all", "toggle_follow"}
2145
+ LIVE_ONLY = {"skip_model", "remove_job", "cancel_all", "toggle_follow"}
2128
2146
  DONE_ONLY = set()
2129
2147
 
2130
2148
  def __init__(self, jobs, parallel=None):
@@ -2180,6 +2198,8 @@ class RunScreen(Screen):
2180
2198
  def check_action(self, action, parameters):
2181
2199
  if action == "watch_next":
2182
2200
  return self.running and self.side_by_side
2201
+ if action == "remove_job": # only while something is left to remove
2202
+ return self.running and any(self.app.engine.removable(j) is None for j in self.jobs)
2183
2203
  if action in self.LIVE_ONLY:
2184
2204
  return self.running
2185
2205
  if action in self.DONE_ONLY:
@@ -2219,7 +2239,7 @@ class RunScreen(Screen):
2219
2239
  with Vertical(id="top"):
2220
2240
  yield Static(id="overall")
2221
2241
  yield ProgressBar(id="bar", show_eta=False)
2222
- yield DataTable(id="queue", cursor_type="none", zebra_stripes=True)
2242
+ yield DataTable(id="queue", cursor_type="row", zebra_stripes=True)
2223
2243
  yield Static(id="current")
2224
2244
  with Horizontal(id="done-bar"):
2225
2245
  yield Button("New run [n]", id="done-new", variant="success")
@@ -2365,10 +2385,12 @@ class RunScreen(Screen):
2365
2385
  self.total += j.total
2366
2386
  self.query_one("#bar", ProgressBar).update(total=max(self.total, 1))
2367
2387
  self.event(f"added to this run: {', '.join(j.key for j in d['jobs'])}")
2388
+ self.refresh_bindings()
2368
2389
  self.update_overall()
2369
2390
  elif kind in ("job_started", "job_update", "job_done"):
2370
2391
  job = d["job"]
2371
2392
  self.update_job(job)
2393
+ self.refresh_bindings() # x shows while a pack can still be removed
2372
2394
  if kind == "job_started" and d.get("resumed"):
2373
2395
  earlier = (f" ({fmt_secs(d['earlier_s'])} over {d['sittings']} earlier sitting(s))"
2374
2396
  if d.get("sittings") else "")
@@ -2611,13 +2633,63 @@ class RunScreen(Screen):
2611
2633
 
2612
2634
  # -- actions
2613
2635
  def action_skip_model(self):
2614
- if self.running:
2615
- if self.side_by_side and self.watch:
2616
- self.app.engine.skip_model(self.watch)
2617
- self.notify(f"Skipping {self.watch}; the others keep going…")
2636
+ if not self.running:
2637
+ return
2638
+ one = self.watch if self.side_by_side and self.watch else None
2639
+ names = [one] if one else self.active()
2640
+ if not names:
2641
+ return
2642
+
2643
+ def skip(yes):
2644
+ if not yes or not self.running:
2645
+ return
2646
+ if one:
2647
+ self.app.engine.skip_model(one)
2648
+ self.notify(f"Skipping {one}; the others keep going…")
2618
2649
  else:
2619
2650
  self.app.engine.skip_model()
2620
2651
  self.notify("Skipping the current model…")
2652
+ self.app.push_screen(ConfirmScreen(
2653
+ f"Skip {', '.join(names)}? Answers so far are kept (a later run picks up from them); the rest "
2654
+ f"of {'its' if len(names) == 1 else 'their'} questions in this run are skipped.", no_keys=("k",)), skip)
2655
+
2656
+ def action_remove_job(self):
2657
+ """Take the highlighted pack out of the run if it hasn't started (the reverse of adding it)."""
2658
+ if not self.running:
2659
+ return
2660
+ q = self.query_one("#queue", DataTable)
2661
+ try:
2662
+ key = q.coordinate_to_cell_key(q.cursor_coordinate).row_key.value
2663
+ except Exception:
2664
+ key = None
2665
+ job = next((j for j in self.jobs if j.key == key), None)
2666
+ if job is None:
2667
+ self.notify("Highlight a pack in the queue (click it or use ↑/↓ there), then press x.")
2668
+ return
2669
+ why = self.app.engine.removable(job)
2670
+ if why:
2671
+ self.notify(f"{job.label} · {job.pack.label} can't be removed: {why}.")
2672
+ return
2673
+
2674
+ def remove(yes):
2675
+ if not yes:
2676
+ return
2677
+ removed = self.app.engine.remove_jobs({job.key})
2678
+ if not removed:
2679
+ self.notify(f"{job.label} · {job.pack.label} started meanwhile, so it stays (k skips the model).")
2680
+ return
2681
+ for j in removed:
2682
+ q.remove_row(j.key)
2683
+ self.total -= j.total
2684
+ self.query_one("#bar", ProgressBar).update(total=max(self.total, 1))
2685
+ self.event(f"removed from this run: {', '.join(j.key for j in removed)}")
2686
+ self.update_overall()
2687
+ self.refresh_bindings()
2688
+ self.app.refresh_live()
2689
+ self.notify(f"Removed from the run: {job.label} · {job.pack.label}.")
2690
+ self.app.push_screen(ConfirmScreen(
2691
+ f"Remove {job.label} · {job.pack.label} from this run? It hasn't started, so nothing is lost; "
2692
+ "Start can add it again later.", no_keys=("x",)), remove)
2621
2693
 
2622
2694
  def action_watch_next(self):
2623
2695
  """Stream the next model running alongside (from its next answer)."""
@@ -3489,7 +3561,7 @@ class EvalsApp(App):
3489
3561
  return
3490
3562
  run.handle("jobs_added", {"jobs": added})
3491
3563
  models = sorted({j.label for j in added})
3492
- self.notify(f"Added {len(added)} pack run(s) to the run going: {', '.join(models)}. w shows it.", timeout=10)
3564
+ self.notify(f"Added {len(added)} pack run(s) to the run going: {', '.join(models)}. x removes a pack not started yet.", timeout=10)
3493
3565
  self.refresh_live()
3494
3566
 
3495
3567
  def enqueue(self, item):
@@ -1030,6 +1030,141 @@ class AddToRun(unittest.TestCase):
1030
1030
  loads = [d["label"] for k, d in events if k == "model_loading"]
1031
1031
  self.assertEqual(loads.count("a"), 2) # a's new pack: after it ended
1032
1032
 
1033
+ def test_engine_removes_jobs_not_started(self):
1034
+ from tuieval import engine
1035
+ events = []
1036
+ e = engine.Engine(lambda kind, **d: events.append((kind, d)), root=self.ws)
1037
+ jobs = e.plan(["a"], ["apps", "other"], None, "certify", False, {})
1038
+ t = threading.Thread(target=e.run, args=(jobs, 1))
1039
+ t.start()
1040
+ deadline = time.time() + 180 # CI machines can take long to start servers
1041
+ while not any(k == "request_done" for k, _ in events) and time.time() < deadline:
1042
+ time.sleep(0.05)
1043
+ added, _ = e.add_jobs(e.plan(["b"], ["apps"], None, "certify", False, {}))
1044
+ self.assertEqual([j.key for j in added], ["b/apps"])
1045
+ self.assertEqual(e.remove_jobs({"a/apps"}), []) # answering: k skips it instead
1046
+ # a's next pack (its server is up) and b (never started) both go
1047
+ self.assertEqual(sorted(j.key for j in e.remove_jobs({"a/other", "b/apps"})), ["a/other", "b/apps"])
1048
+ self.finish(e, t)
1049
+ self.assertEqual([(j.key, j.status) for j in jobs], [("a/apps", "done")])
1050
+ self.assertNotIn(("model_loading", "b"), [(k, d.get("label")) for k, d in events])
1051
+ self.assertFalse(os.path.exists(os.path.join(self.ws, "results", "a", "other.json")))
1052
+ self.assertEqual(e.remove_jobs({"a/apps"}), []) # the run is over
1053
+
1054
+ def test_tui_removes_a_pack_from_the_run(self):
1055
+ path = os.path.join(self.ws, "models.toml")
1056
+ write(path, read(path).replace('"--delay", "0.2"', '"--delay", "3"')) # still running while it's asked
1057
+ code = textwrap.dedent("""
1058
+ import asyncio, json, time
1059
+ from tuieval.tui import EvalsApp, RunScreen, ConfirmScreen
1060
+ from textual.widgets import SelectionList, DataTable
1061
+ async def go():
1062
+ app = EvalsApp({})
1063
+ async with app.run_test(size=(160, 50)) as pilot:
1064
+ await pilot.pause()
1065
+ s = app.screen
1066
+ s.query_one("#suites", SelectionList).select("apps")
1067
+ s.query_one("#suites", SelectionList).select("other")
1068
+ s.selected_models = {"a", "b"}
1069
+ s.refresh_models()
1070
+ s.query_one("#tier-certify").value = True
1071
+ await pilot.pause()
1072
+ s.action_start()
1073
+ deadline = time.time() + 180 # CI machines can take long to start servers
1074
+ while time.time() < deadline:
1075
+ await pilot.pause(0.1)
1076
+ if isinstance(app.screen, RunScreen) and app.screen.counts()[0]:
1077
+ break
1078
+ run, out = app.screen, {}
1079
+ q = run.query_one("#queue", DataTable)
1080
+ total = run.total
1081
+ q.move_cursor(row=q.get_row_index("b/apps"))
1082
+ await pilot.press("x")
1083
+ await pilot.pause()
1084
+ out["asked"] = isinstance(app.screen, ConfirmScreen) and app.screen.focused.id == "no"
1085
+ await pilot.press("n")
1086
+ await pilot.pause()
1087
+ out["kept"] = [j.key for j in run.jobs]
1088
+ q.move_cursor(row=q.get_row_index("b/apps"))
1089
+ await pilot.press("x")
1090
+ await pilot.pause()
1091
+ await pilot.press("y")
1092
+ await pilot.pause()
1093
+ out["after"] = [[j.key for j in run.jobs], list(map(str, (k.value for k in q.rows))),
1094
+ total - run.total]
1095
+ q.move_cursor(row=q.get_row_index("a/apps")) # running: stays, nothing asked
1096
+ await pilot.press("x")
1097
+ await pilot.pause()
1098
+ out["running"] = [isinstance(app.screen, ConfirmScreen), "a/apps" in [j.key for j in run.jobs]]
1099
+ app.engine.cancel()
1100
+ deadline = time.time() + 60
1101
+ while time.time() < deadline and run.running:
1102
+ await pilot.pause(0.2)
1103
+ print(json.dumps(out))
1104
+ asyncio.run(go())
1105
+ """)
1106
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1107
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
1108
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1109
+ self.assertIsInstance(out, dict, out)
1110
+ self.assertTrue(out["asked"])
1111
+ self.assertIn("b/apps", out["kept"])
1112
+ jobs, rows, fewer = out["after"]
1113
+ self.assertNotIn("b/apps", jobs)
1114
+ self.assertNotIn("b/apps", rows)
1115
+ self.assertEqual(fewer, 4) # its 4 questions are no longer counted
1116
+ self.assertEqual(out["running"], [False, True])
1117
+
1118
+ def test_tui_asks_before_skipping_a_model(self):
1119
+ path = os.path.join(self.ws, "models.toml")
1120
+ write(path, read(path).replace('"--delay", "0.2"', '"--delay", "3"')) # still running while it's asked
1121
+ code = textwrap.dedent("""
1122
+ import asyncio, json, time
1123
+ from tuieval.tui import EvalsApp, RunScreen, ConfirmScreen
1124
+ from textual.widgets import SelectionList
1125
+ async def go():
1126
+ app = EvalsApp({})
1127
+ async with app.run_test(size=(160, 50)) as pilot:
1128
+ await pilot.pause()
1129
+ s = app.screen
1130
+ s.query_one("#suites", SelectionList).select("apps")
1131
+ s.selected_models = {"a"}
1132
+ s.refresh_models()
1133
+ s.query_one("#tier-certify").value = True
1134
+ await pilot.pause()
1135
+ s.action_start()
1136
+ deadline = time.time() + 180 # CI machines can take long to start servers
1137
+ while time.time() < deadline:
1138
+ await pilot.pause(0.1)
1139
+ if isinstance(app.screen, RunScreen) and app.screen.counts()[0]:
1140
+ break
1141
+ run, out = app.screen, {}
1142
+ for keys in (["k", "enter"], ["k", "k"], ["k", "n"], ["k", "escape"]): # each one: no
1143
+ await pilot.press(keys[0])
1144
+ await pilot.pause()
1145
+ out.setdefault("asked", []).append(isinstance(app.screen, ConfirmScreen)
1146
+ and app.screen.focused.id == "no")
1147
+ await pilot.press(keys[1])
1148
+ await pilot.pause()
1149
+ out["kept"] = [run.running, app.screen is run, [j.status for j in run.jobs]]
1150
+ await pilot.press("k")
1151
+ await pilot.pause()
1152
+ await pilot.press("y")
1153
+ deadline = time.time() + 120
1154
+ while time.time() < deadline and run.running:
1155
+ await pilot.pause(0.2)
1156
+ out["skipped"] = [j.status for j in run.jobs]
1157
+ print(json.dumps(out))
1158
+ asyncio.run(go())
1159
+ """)
1160
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1161
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
1162
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1163
+ self.assertIsInstance(out, dict, out)
1164
+ self.assertEqual(out["asked"], [True] * 4)
1165
+ self.assertEqual(out["kept"], [True, True, ["running"]])
1166
+ self.assertEqual(out["skipped"], ["skipped"])
1167
+
1033
1168
  def test_tui_adds_or_queues_while_running(self):
1034
1169
  code = textwrap.dedent("""
1035
1170
  import asyncio, json, time
File without changes