tuieval 0.2.0.dev28__tar.gz → 0.2.0.dev33__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 (74) hide show
  1. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/PKG-INFO +2 -4
  3. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/README.md +1 -3
  4. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/engine.py +71 -27
  6. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/tui.py +61 -8
  7. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/tests/test_tuieval.py +154 -0
  8. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/RELEASING.md +0 -0
  11. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/README.md +0 -0
  12. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/favicon.ico +0 -0
  13. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/readme-header.png +0 -0
  14. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/social-preview.png +0 -0
  15. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-1024.png +0 -0
  16. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-128.png +0 -0
  17. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-16.png +0 -0
  18. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-16.svg +0 -0
  19. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-256.png +0 -0
  20. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-32.png +0 -0
  21. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-48.png +0 -0
  22. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-512.png +0 -0
  23. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-64.png +0 -0
  24. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  25. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/brand/tuieval-icon.svg +0 -0
  26. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/images/tui-setup.png +0 -0
  27. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/models.md +0 -0
  28. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/docs/writing-packs.md +0 -0
  29. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/pyproject.toml +0 -0
  30. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/__init__.py +0 -0
  31. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/__main__.py +0 -0
  32. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/builtin.toml +0 -0
  33. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/cli.py +0 -0
  34. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/client.py +0 -0
  35. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/compare.py +0 -0
  36. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/doctor.py +0 -0
  37. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/export.py +0 -0
  38. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/graders/__init__.py +0 -0
  39. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/graders/answer.py +0 -0
  40. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/graders/code.py +0 -0
  41. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/graders/rag.py +0 -0
  42. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/graders/reply.py +0 -0
  43. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/graders/tool_call.py +0 -0
  44. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/machines.py +0 -0
  45. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/onboard.py +0 -0
  46. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/packs.py +0 -0
  47. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/profiles.py +0 -0
  48. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/remove.py +0 -0
  49. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/run_evals.py +0 -0
  50. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/scaffold.py +0 -0
  51. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/selftest.py +0 -0
  52. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/models.toml +0 -0
  53. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  54. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/answer/system.txt +0 -0
  55. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  56. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/code/pack.toml +0 -0
  57. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/code/system.txt +0 -0
  58. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  59. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  60. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/rag/system.txt +0 -0
  61. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  62. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  63. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/reply/system.txt +0 -0
  64. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  66. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  67. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  69. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/tune.py +0 -0
  70. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/verdict.py +0 -0
  71. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/watch_proxy.py +0 -0
  72. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/workspace.py +0 -0
  73. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/src/tuieval/yamlout.py +0 -0
  74. {tuieval-0.2.0.dev28 → tuieval-0.2.0.dev33}/tests/mock_server.py +0 -0
@@ -3,6 +3,7 @@
3
3
  ## Unreleased
4
4
 
5
5
  - Run several models at a time: `parallel_models` per machine, **Models at a time** in the TUI, or `tuieval run --parallel N` (default 1). Models wait for memory to fit, and answers note what ran alongside them.
6
+ - More evals during a run: `n` opens Setup, and Start adds them to the run going (same tier) or queues them after it.
6
7
  - A new workspace's `models.toml` is 23 lines: the llama, local and OpenRouter servers and the defaults are built in (docs/models.md lists them in full; a server defined in `models.toml` replaces the built-in one with that name). Existing workspaces keep their settings; built-in servers they don't define become available, and adding a model still prefers the workspace's own servers.
7
8
  - Less to take in at first: until something has run, the setup screen leaves out context sizes, tuning state and the Tune key; Presets and Hide are off the footer (`?` lists every key); `tuieval help` groups commands into Start here, Day to day and Advanced.
8
9
  - Results lead with a plain summary per model ("ready for Support; not decided yet for Coding → run Certify to finish"), in the TUI, `tuieval verdict` (with the command to run next) and the report; the statistics follow.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev28
3
+ Version: 0.2.0.dev33
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
@@ -26,8 +26,6 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  <p align="center"><img src="https://raw.githubusercontent.com/ashe-wb/tuieval/main/docs/images/brand/readme-header.png" alt="tuieval" width="100%"></p>
28
28
 
29
- **Find out which model you can trust with your work, on your own questions, in the terminal.**
30
-
31
29
  Run the same eval packs against local models (llama.cpp GGUFs, LM Studio, Ollama, vLLM; tiny, dense or MoE) and against any frontier model on OpenRouter, side by side.
32
30
 
33
31
  tuieval measures **accuracy, speed and token use** together, and gives a **PASS / FAIL / INCONCLUSIVE** verdict per use case. Grading is automatic; there's no LLM judge.
@@ -137,7 +135,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
137
135
 
138
136
  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.
139
137
  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.
140
- 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). Runs started while one is going are queued.
138
+ 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.
141
139
  4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
142
140
 
143
141
  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.
@@ -1,7 +1,5 @@
1
1
  <p align="center"><img src="https://raw.githubusercontent.com/ashe-wb/tuieval/main/docs/images/brand/readme-header.png" alt="tuieval" width="100%"></p>
2
2
 
3
- **Find out which model you can trust with your work, on your own questions, in the terminal.**
4
-
5
3
  Run the same eval packs against local models (llama.cpp GGUFs, LM Studio, Ollama, vLLM; tiny, dense or MoE) and against any frontier model on OpenRouter, side by side.
6
4
 
7
5
  tuieval measures **accuracy, speed and token use** together, and gives a **PASS / FAIL / INCONCLUSIVE** verdict per use case. Grading is automatic; there's no LLM judge.
@@ -111,7 +109,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
111
109
 
112
110
  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.
113
111
  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.
114
- 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). Runs started while one is going are queued.
112
+ 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.
115
113
  4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
116
114
 
117
115
  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.dev28'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev28')
21
+ __version__ = version = '0.2.0.dev33'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev33')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -698,6 +698,8 @@ class Engine:
698
698
  self._lanes_lock = threading.Lock()
699
699
  self._procs = []
700
700
  self._stop_lock = threading.Lock()
701
+ self._run_lock = threading.Lock()
702
+ self._run = None # the run going: {"jobs", "pending", "open", "machine", "cond"} (add_jobs)
701
703
  self._header_cache = {}
702
704
  self._serving = {}
703
705
  self.tuning_dir = os.path.join(root, "tuning")
@@ -1552,29 +1554,38 @@ class Engine:
1552
1554
  # ---- running
1553
1555
  def run(self, jobs, parallel=None):
1554
1556
  """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1555
- models are served at a time (default: parallel_models for this machine)."""
1557
+ models are served at a time (default: parallel_models for this machine). add_jobs() can add
1558
+ more while it runs; they run after the ones before them."""
1556
1559
  self._cancel.clear()
1557
1560
  self.emit("queue_started", jobs=jobs)
1558
1561
  models = sorted({j.label for j in jobs if j.status == "waiting"})
1562
+ machine = bool(jobs) and self.needs_machine(jobs)
1559
1563
  lock = (self.machine_lock(f"{jobs[0].tier} run: {', '.join(models[:3])}"
1560
1564
  + (f" +{len(models) - 3}" if len(models) > 3 else ""))
1561
- if jobs and self.needs_machine(jobs) else contextlib.nullcontext())
1565
+ if machine else contextlib.nullcontext())
1566
+ order = []
1567
+ for j in jobs:
1568
+ if j.label not in order:
1569
+ order.append(j.label)
1570
+ groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1571
+ with self._run_lock:
1572
+ self._run = {"jobs": jobs, "pending": [g for g in groups if g[1]], "open": True, "machine": machine,
1573
+ "cond": threading.Condition(self._run_lock)}
1562
1574
  try:
1563
1575
  with lock:
1564
- order = []
1565
- for j in jobs:
1566
- if j.label not in order:
1567
- order.append(j.label)
1568
- groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1569
- self._run_lanes([g for g in groups if g[1]], max(1, int(parallel or self.parallel_models())))
1576
+ self._run_lanes(max(1, int(parallel or self.parallel_models())))
1570
1577
  if self._cancel.is_set():
1571
1578
  raise Cancelled()
1572
1579
  except Cancelled:
1580
+ with self._run_lock:
1581
+ self._run["open"] = False
1573
1582
  for j in jobs:
1574
1583
  if j.status in ("waiting", "loading", "running"):
1575
1584
  j.status, j.note = "skipped", "cancelled (progress kept)" if j.done else "cancelled"
1576
1585
  self.emit("job_done", job=j)
1577
1586
  finally:
1587
+ with self._run_lock:
1588
+ self._run["open"] = False
1578
1589
  changes = []
1579
1590
  if any(j.status == "done" and j.started and j.tier != "smoke" for j in jobs):
1580
1591
  try:
@@ -1586,12 +1597,36 @@ class Engine:
1586
1597
  self.emit("verdicts", changes=changes)
1587
1598
  self.emit("queue_done", jobs=jobs, cancelled=self._cancel.is_set())
1588
1599
 
1589
- def _run_lanes(self, groups, parallel):
1600
+ def add_jobs(self, new):
1601
+ """Add planned jobs to the run going on, after its other jobs. Returns (added, why not): a
1602
+ pack already waiting or running in it isn't added twice, and nothing is added once the run is
1603
+ finishing or cancelled, or when it holds no machine lock (hosted only) and these need one.
1604
+ Runs on the caller's thread, so the caller shows what was added (no event)."""
1605
+ with self._run_lock:
1606
+ r = self._run
1607
+ if not r or not r["open"] or self._cancel.is_set():
1608
+ return [], "the run is finishing"
1609
+ if not r["machine"] and self.needs_machine(new):
1610
+ return [], "the run going uses only hosted models, and these need this machine"
1611
+ busy = {j.key for j in r["jobs"] if j.status in ("waiting", "loading", "running")}
1612
+ added = [j for j in new if j.status == "waiting" and j.key not in busy]
1613
+ r["jobs"].extend(added)
1614
+ for j in added: # a model still waiting takes them on; otherwise it comes again at the end
1615
+ group = next((g for g in r["pending"] if g[0] == j.label), None)
1616
+ if group:
1617
+ group[1].append(j)
1618
+ else:
1619
+ r["pending"].append((j.label, [j]))
1620
+ r["cond"].notify_all()
1621
+ return added, None if added else "everything picked is already in the run"
1622
+
1623
+ def _run_lanes(self, parallel):
1590
1624
  """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1591
1625
  model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1592
- next to the ones running (memory_need_gb; an unknown need is left to the setting)."""
1593
- pending, running, told = list(groups), {}, set()
1594
- cond = threading.Condition()
1626
+ next to the ones running (memory_need_gb; an unknown need is left to the setting). Lanes
1627
+ wait for jobs added meanwhile (add_jobs) until every model is done."""
1628
+ pending, running, told = self._run["pending"], {}, set()
1629
+ cond = self._run["cond"]
1595
1630
 
1596
1631
  def blocked(label):
1597
1632
  """Why the next model can't start now (None: it can)."""
@@ -1614,20 +1649,29 @@ class Engine:
1614
1649
 
1615
1650
  def lane():
1616
1651
  while True:
1617
- with cond:
1618
- while True:
1619
- if self._cancel.is_set() or not pending:
1620
- return
1621
- label = pending[0][0]
1622
- why = blocked(label)
1623
- if why is None:
1624
- label, mine = pending.pop(0)
1625
- running[label] = self.memory_need_gb(self.model(label))
1626
- break
1627
- if why and label not in told:
1628
- told.add(label)
1629
- self.emit("model_waiting", label=label, message=f"{label} waits: {why}")
1652
+ mine, note = None, None
1653
+ with cond: # events go out after the lock is released: the UI may be waiting for it (add_jobs)
1654
+ if self._cancel.is_set() or not pending and not running:
1655
+ self._run["open"] = False # nothing more can be added
1656
+ cond.notify_all()
1657
+ return
1658
+ # in order, except that a model added again while it runs waits for itself to end
1659
+ # without holding up the ones behind it
1660
+ nxt = next((i for i, g in enumerate(pending) if g[0] not in running), None)
1661
+ label = pending[nxt][0] if nxt is not None else None
1662
+ why = blocked(label) if label else "" # none can start: a model still runs, more may come
1663
+ if why is None:
1664
+ label, mine = pending.pop(nxt)
1665
+ running[label] = self.memory_need_gb(self.model(label))
1666
+ elif why and label not in told:
1667
+ told.add(label)
1668
+ note = f"{label} waits: {why}"
1669
+ else:
1630
1670
  cond.wait(1.0)
1671
+ if note:
1672
+ self.emit("model_waiting", label=label, message=note)
1673
+ if mine is None:
1674
+ continue
1631
1675
  try:
1632
1676
  self._run_lane(label, mine)
1633
1677
  finally:
@@ -1635,10 +1679,10 @@ class Engine:
1635
1679
  running.pop(label, None)
1636
1680
  cond.notify_all()
1637
1681
 
1638
- if parallel == 1 or len(groups) == 1:
1682
+ if parallel == 1:
1639
1683
  lane()
1640
1684
  return
1641
- threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(min(parallel, len(groups)))]
1685
+ threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel)]
1642
1686
  for t in threads:
1643
1687
  t.start()
1644
1688
  for t in threads:
@@ -294,7 +294,7 @@ HELP = {
294
294
  parallel_models in models.toml, default 1). Answers note the models they ran alongside.
295
295
 
296
296
  [b]Keys[/b]
297
- s start (or queue it behind a run that's going) r results
297
+ s start (with a run going: add to it, or queue it) r results
298
298
  a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
299
299
  e pick which tests of the highlighted pack run p save or load a selection (preset)
300
300
  t tune the ticked models' speed flags here (after x hide or unhide a model
@@ -310,7 +310,7 @@ with the model's reasoning and answer as they stream, then recent results with t
310
310
 
311
311
  [b]Keys[/b]
312
312
  k skip the current model c cancel everything (finished answers are kept; it resumes next time)
313
- f pause or resume auto-scroll r results n set up a new run (this one keeps going)
313
+ f pause or resume auto-scroll r results n more evals: add them to this run or queue them
314
314
  w runs and queue esc back to Setup (the run keeps going)
315
315
  v with several models at a time: stream the next one (k skips the one streaming)""",
316
316
  "ResultsScreen": """[b]Results[/b]
@@ -878,7 +878,8 @@ class SetupScreen(Screen):
878
878
  """Says what Start will do when something is already running, here or in another window."""
879
879
  app = self.app
880
880
  if app.is_busy():
881
- return (f"\n[cyan]{app.busy_text()}: Start adds this to the queue as #{len(app.queue) + 1} "
881
+ add = "add this to the run going or " if isinstance(app.active_session, RunScreen) else ""
882
+ return (f"\n[cyan]{app.busy_text()}: Start lets you {add}queue it as #{len(app.queue) + 1} "
882
883
  "(w shows the queue).[/cyan]")
883
884
  holder = app.engine.machine_holder()
884
885
  if holder:
@@ -1464,8 +1465,8 @@ class QueuedRun:
1464
1465
  def line(self):
1465
1466
  return f"{self.title} [dim]~{fmt_secs(self.est)} · queued {time.strftime('%H:%M', time.localtime(self.queued))}[/dim]"
1466
1467
 
1467
- def make(self, eng):
1468
- """(RunScreen, None), or (None, why it can't start) when its turn comes."""
1468
+ def plan(self, eng):
1469
+ """(jobs, None), or (None, why it can't run) now."""
1469
1470
  sel = self.sel
1470
1471
  jobs = eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], sel["force"] or self.rerun,
1471
1472
  self.tests(eng))
@@ -1476,7 +1477,12 @@ class QueuedRun:
1476
1477
  if new:
1477
1478
  return None, (f"it would now start over {', '.join(new)}, which wasn't confirmed when it was queued. "
1478
1479
  "Start it again from Setup to see why")
1479
- return RunScreen(jobs, sel.get("parallel")), None
1480
+ return jobs, None
1481
+
1482
+ def make(self, eng):
1483
+ """(RunScreen, None), or (None, why it can't start) when its turn comes."""
1484
+ jobs, problem = self.plan(eng)
1485
+ return (RunScreen(jobs, self.sel.get("parallel")), None) if jobs else (None, problem)
1480
1486
 
1481
1487
 
1482
1488
  class QueuedTune:
@@ -1807,7 +1813,7 @@ class RunScreen(Screen):
1807
1813
  Binding("c", "cancel_all", "Cancel all", priority=True),
1808
1814
  Binding("f", "toggle_follow", "Pause scroll", priority=True),
1809
1815
  Binding("v", "watch_next", "Next model", priority=True),
1810
- Binding("n", "new_run", "New run", priority=True),
1816
+ Binding("n", "new_run", "More evals", priority=True),
1811
1817
  Binding("r", "results", "Results", priority=True),
1812
1818
  Binding("w", "runs", "Runs", priority=True),
1813
1819
  Binding("escape", "back", "Setup", priority=True),
@@ -1815,7 +1821,7 @@ class RunScreen(Screen):
1815
1821
  ]
1816
1822
  kind = "Run"
1817
1823
  LIVE_ONLY = {"skip_model", "cancel_all", "toggle_follow"}
1818
- DONE_ONLY = {"new_run"}
1824
+ DONE_ONLY = set()
1819
1825
 
1820
1826
  def __init__(self, jobs, parallel=None):
1821
1827
  super().__init__()
@@ -2027,6 +2033,16 @@ class RunScreen(Screen):
2027
2033
  if self.watched(kind, d):
2028
2034
  self.req_started = None
2029
2035
  self.update_overall()
2036
+ elif kind == "jobs_added":
2037
+ q = self.query_one("#queue", DataTable)
2038
+ for j in d["jobs"]:
2039
+ if j.key not in q.rows:
2040
+ q.add_row(j.label, j.pack.label, "", "", "", "", "", "", key=j.key)
2041
+ self.update_job(j)
2042
+ self.total += j.total
2043
+ self.query_one("#bar", ProgressBar).update(total=max(self.total, 1))
2044
+ self.event(f"added to this run: {', '.join(j.key for j in d['jobs'])}")
2045
+ self.update_overall()
2030
2046
  elif kind in ("job_started", "job_update", "job_done"):
2031
2047
  job = d["job"]
2032
2048
  self.update_job(job)
@@ -2262,6 +2278,9 @@ class RunScreen(Screen):
2262
2278
 
2263
2279
  @on(Button.Pressed, "#done-new")
2264
2280
  def action_new_run(self):
2281
+ if self.running:
2282
+ self.notify("Pick models and packs, then Start: add them to this run or queue them after it.",
2283
+ timeout=8)
2265
2284
  self.app.back_to_setup() # Setup still holds the last selection
2266
2285
  self.app.screen.query_one("#models").focus()
2267
2286
 
@@ -2923,6 +2942,40 @@ class EvalsApp(App):
2923
2942
  self.notify("Everything selected had results; running it again.")
2924
2943
  self.add_session(screen)
2925
2944
  return
2945
+ run = self.active_session
2946
+ if item.kind == "Run" and isinstance(run, RunScreen) and run.running \
2947
+ and item.sel["tier"] != run.jobs[0].tier:
2948
+ self.notify(f"The run going is {run.jobs[0].tier.title()} and this is {item.sel['tier'].title()}, "
2949
+ "so it can't join it.", timeout=10)
2950
+ elif item.kind == "Run" and isinstance(run, RunScreen) and run.running:
2951
+ def picked(choice):
2952
+ if choice == "add":
2953
+ self.add_to_run(run, item)
2954
+ elif choice == "queue":
2955
+ self.enqueue(item)
2956
+ what = f"{', '.join(item.sel['models'][:4])}{' …' if len(item.sel['models']) > 4 else ''} · " \
2957
+ f"{', '.join(item.sel['packs'][:4])}{' …' if len(item.sel['packs']) > 4 else ''}"
2958
+ self.push_screen(ChoiceScreen(
2959
+ f"A run is going: {run.live_text()}.\n\nAdd {what} to it? Its models run after the ones "
2960
+ "already in it.\nOr queue it as a separate run that starts when this one ends.",
2961
+ [("add", "Add to this run", "success"), ("queue", f"Queue it as #{len(self.queue) + 1}", "primary"),
2962
+ ("keep", "Cancel", "default")]), picked)
2963
+ return
2964
+ self.enqueue(item)
2965
+
2966
+ def add_to_run(self, run, item):
2967
+ jobs, problem = item.plan(self.engine)
2968
+ added, why = self.engine.add_jobs(jobs) if jobs else ([], problem)
2969
+ if not added:
2970
+ self.notify(f"Not added: {why}. Queued it instead.", severity="warning", timeout=10)
2971
+ self.enqueue(item)
2972
+ return
2973
+ run.handle("jobs_added", {"jobs": added})
2974
+ models = sorted({j.label for j in added})
2975
+ self.notify(f"Added {len(added)} pack run(s) to the run going: {', '.join(models)}. w shows it.", timeout=10)
2976
+ self.refresh_live()
2977
+
2978
+ def enqueue(self, item):
2926
2979
  ahead = self.busy_text()
2927
2980
  self.queue.append(item)
2928
2981
  self.notify(f"Queued #{len(self.queue)}: {item.title}. It starts by itself after: {ahead}. "
@@ -522,6 +522,160 @@ class Parallel(unittest.TestCase):
522
522
  self.assertEqual(sum(r["pass"] for r in self.results("b")["results"]), 6)
523
523
 
524
524
 
525
+ class AddToRun(unittest.TestCase):
526
+ """More evals while a run is going: added to it (they run after its other jobs) or queued after it."""
527
+
528
+ def setUp(self):
529
+ self.tmp = tempfile.TemporaryDirectory()
530
+ self.ws = os.path.join(self.tmp.name, "ws")
531
+ tuieval(self.tmp.name, "init", self.ws)
532
+ for name in ("apps", "other"):
533
+ d = os.path.join(self.ws, "packs", name)
534
+ os.makedirs(d)
535
+ write(os.path.join(d, "pack.toml"), f'label = "{name}"\n[certify]\nrepeat = 1\n[gate]\nmin_accuracy = 0.1\n')
536
+ write(os.path.join(d, "tests.yaml"), "".join(
537
+ f"- {{id: {t}, input: {name} question {t}, expected: 1, reference: 'ANSWER: 1', difficulty: easy}}\n"
538
+ for t in "abcd"))
539
+ mock = [sys.executable, os.path.join(HERE, "mock_server.py"), "--port", "{port}", "--packs",
540
+ os.path.join(self.ws, "packs"), "--model", "{served_name}", "--delay", "0.2"]
541
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
542
+ f.write(f"\n[servers.mk]\ncmd = {json.dumps(mock)}\nport = {free_port()}\n")
543
+ for label in "ab":
544
+ f.write(f'\n[[models]]\nlabel = "{label}"\nserver = "mk"\nmodel = "{label}"\n')
545
+
546
+ def tearDown(self):
547
+ self.tmp.cleanup()
548
+
549
+ def finish(self, e, t, limit=480):
550
+ """Wait for a run thread; CI machines can take half a minute per server start. One that's
551
+ still going after `limit` is cancelled, so it can't run into the next test."""
552
+ t.join(limit)
553
+ if t.is_alive():
554
+ e.cancel()
555
+ t.join(120)
556
+ self.fail(f"the run didn't finish in {limit}s")
557
+
558
+ def test_engine_adds_jobs_to_the_run_going(self):
559
+ from tuieval import engine
560
+ events = []
561
+ e = engine.Engine(lambda kind, **d: events.append((kind, d)), root=self.ws)
562
+ jobs = e.plan(["a"], ["apps"], None, "certify", False, {})
563
+ self.assertEqual(e.add_jobs(e.plan(["b"], ["apps"], None, "certify", False, {})),
564
+ ([], "the run is finishing")) # no run yet
565
+ t = threading.Thread(target=e.run, args=(jobs,))
566
+ t.start()
567
+ deadline = time.time() + 180 # CI machines can take long to start servers
568
+ while not any(k == "request_done" for k, _ in events) and time.time() < deadline:
569
+ time.sleep(0.05)
570
+ added, why = e.add_jobs(e.plan(["b", "a"], ["apps", "other"], None, "certify", False, {}))
571
+ self.assertIsNone(why)
572
+ self.assertEqual(sorted(j.key for j in added), ["a/other", "b/apps", "b/other"]) # a/apps is running
573
+ self.assertEqual(e.add_jobs(e.plan(["b"], ["apps"], None, "certify", False, {}))[1],
574
+ "everything picked is already in the run")
575
+ self.finish(e, t)
576
+ self.assertEqual([j.status for j in jobs], ["done"] * 4)
577
+ self.assertEqual(jobs[0].key, "a/apps")
578
+ started = [d["job"].key for k, d in events if k == "job_started"]
579
+ self.assertEqual(started[0], "a/apps")
580
+ self.assertEqual(e.add_jobs(e.plan(["b"], ["apps"], None, "certify", True, {}))[1], "the run is finishing")
581
+ queue_done = [d for k, d in events if k == "queue_done"]
582
+ self.assertEqual(len(queue_done[0]["jobs"]), 4)
583
+
584
+ def test_added_models_use_a_free_lane(self):
585
+ from tuieval import engine
586
+ path = os.path.join(self.ws, "models.toml") # slower answers: a is surely still answering when b is added
587
+ write(path, read(path).replace('"--delay", "0.2"', '"--delay", "1.0"'))
588
+ events = []
589
+ e = engine.Engine(lambda kind, **d: events.append((kind, d)), root=self.ws)
590
+ jobs = e.plan(["a"], ["apps"], None, "certify", False, {})
591
+ t = threading.Thread(target=e.run, args=(jobs, 2))
592
+ t.start()
593
+ deadline = time.time() + 180 # CI machines can take long to start servers
594
+ while not any(k == "request_done" for k, _ in events) and time.time() < deadline:
595
+ time.sleep(0.05)
596
+ added, why = e.add_jobs(e.plan(["a", "b"], ["other", "apps"], None, "certify", False, {}))
597
+ self.assertEqual(sorted(j.key for j in added), ["a/other", "b/apps", "b/other"])
598
+ self.finish(e, t)
599
+ self.assertEqual([j.status for j in jobs], ["done"] * 4)
600
+ order = [(k, d.get("label") or d["job"].key) for k, d in events if k in ("model_loading", "job_done")]
601
+ self.assertLess(order.index(("model_loading", "b")), order.index(("job_done", "a/apps")), order) # next to a
602
+ a_other = json.loads(read(os.path.join(self.ws, "results", "a", "other.json")))["results"]
603
+ self.assertFalse(any("a" in (r.get("ran_alongside") or []) for r in a_other))
604
+ loads = [d["label"] for k, d in events if k == "model_loading"]
605
+ self.assertEqual(loads.count("a"), 2) # a's new pack: after it ended
606
+
607
+ def test_tui_adds_or_queues_while_running(self):
608
+ code = textwrap.dedent("""
609
+ import asyncio, json, time
610
+ from tuieval.tui import EvalsApp, RunScreen, SetupScreen, ChoiceScreen
611
+ from textual.widgets import SelectionList
612
+ async def go():
613
+ app = EvalsApp({})
614
+ async with app.run_test(size=(160, 50)) as pilot:
615
+ await pilot.pause()
616
+ s = app.screen
617
+ s.query_one("#suites", SelectionList).select("apps")
618
+ s.selected_models = {"a"}
619
+ s.refresh_models()
620
+ s.query_one("#tier-certify").value = True
621
+ await pilot.pause()
622
+ s.action_start()
623
+ deadline = time.time() + 180 # CI machines can take long to start servers
624
+ while time.time() < deadline:
625
+ await pilot.pause(0.1)
626
+ if isinstance(app.screen, RunScreen) and app.screen.counts()[0]:
627
+ break
628
+ run = app.screen
629
+ await pilot.press("n") # more evals while it runs
630
+ await pilot.pause()
631
+ on_setup = isinstance(app.screen, SetupScreen)
632
+ app.screen.selected_models = {"b"}
633
+ app.screen.refresh_models()
634
+ app.screen.action_start()
635
+ await pilot.pause()
636
+ asked = isinstance(app.screen, ChoiceScreen)
637
+ app.screen.dismiss("add")
638
+ await pilot.pause()
639
+ app.screen.query_one("#suites", SelectionList).deselect("apps")
640
+ app.screen.query_one("#suites", SelectionList).select("other")
641
+ app.screen.selected_models = {"a"}
642
+ app.screen.refresh_models()
643
+ app.screen.action_start()
644
+ await pilot.pause()
645
+ app.screen.dismiss("queue")
646
+ await pilot.pause()
647
+ app.screen.query_one("#tier-smoke").value = True # another tier can't join: queued
648
+ await pilot.pause()
649
+ app.screen.action_start()
650
+ await pilot.pause()
651
+ other_tier = not isinstance(app.screen, ChoiceScreen)
652
+ queued = len(app.queue)
653
+ deadline = time.time() + 300
654
+ while time.time() < deadline:
655
+ await pilot.pause(0.2)
656
+ if not run.running and not app.queue and not app.active_session:
657
+ break
658
+ second = app.sessions[1]
659
+ print(json.dumps({"on_setup": on_setup, "asked": asked, "queued": queued, "other_tier": other_tier,
660
+ "run": [(j.key, j.status) for j in run.jobs], "total": run.total,
661
+ "sessions": len(app.sessions),
662
+ "second": [(j.key, j.status) for j in second.jobs]}))
663
+ asyncio.run(go())
664
+ """)
665
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
666
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
667
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
668
+ self.assertIsInstance(out, dict, out)
669
+ self.assertTrue(out["on_setup"])
670
+ self.assertTrue(out["asked"])
671
+ self.assertTrue(out["other_tier"])
672
+ self.assertEqual(out["queued"], 2)
673
+ self.assertEqual(out["run"], [["a/apps", "done"], ["b/apps", "done"]]) # added to the run going
674
+ self.assertEqual(out["total"], 8)
675
+ self.assertEqual(out["sessions"], 3)
676
+ self.assertEqual(out["second"], [["a/other", "done"]]) # queued: its own run after
677
+
678
+
525
679
  class FirstRun(unittest.TestCase):
526
680
  """A new user's first run fails fast with the fix, never hangs or shows a bare Python error."""
527
681
 
File without changes
File without changes