tuieval 0.2.0.dev79__tar.gz → 0.2.0.dev86__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.dev79 → tuieval-0.2.0.dev86}/CHANGELOG.md +3 -0
  2. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/models.md +3 -1
  4. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/engine.py +99 -39
  6. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/run_evals.py +9 -2
  7. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/tui.py +60 -9
  8. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/tests/test_tuieval.py +142 -2
  9. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/.gitignore +0 -0
  10. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/LICENSE +0 -0
  11. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/README.md +0 -0
  12. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/RELEASING.md +0 -0
  13. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/README.md +0 -0
  14. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/favicon.ico +0 -0
  15. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/readme-header.png +0 -0
  16. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/social-preview.png +0 -0
  17. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-1024.png +0 -0
  18. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-128.png +0 -0
  19. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-16.png +0 -0
  20. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-16.svg +0 -0
  21. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-256.png +0 -0
  22. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-32.png +0 -0
  23. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-48.png +0 -0
  24. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-512.png +0 -0
  25. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-64.png +0 -0
  26. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  27. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/brand/tuieval-icon.svg +0 -0
  28. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/tui-answer-reasoning.png +0 -0
  29. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/tui-run-reasoning.png +0 -0
  30. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/images/tui-setup.png +0 -0
  31. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/docs/writing-packs.md +0 -0
  32. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/pyproject.toml +0 -0
  33. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/__init__.py +0 -0
  34. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/__main__.py +0 -0
  35. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/builtin.toml +0 -0
  36. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/cli.py +0 -0
  37. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/client.py +0 -0
  38. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/compare.py +0 -0
  39. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/doctor.py +0 -0
  40. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/export.py +0 -0
  41. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/graders/__init__.py +0 -0
  42. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/graders/answer.py +0 -0
  43. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/graders/code.py +0 -0
  44. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/graders/rag.py +0 -0
  45. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/graders/reply.py +0 -0
  46. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/graders/tool_call.py +0 -0
  47. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/scaffold.py +0 -0
  55. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/selftest.py +0 -0
  56. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/models.toml +0 -0
  57. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  58. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/answer/system.txt +0 -0
  59. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  60. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/code/pack.toml +0 -0
  61. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/code/system.txt +0 -0
  62. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  63. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  64. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/rag/system.txt +0 -0
  65. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  66. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  67. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/reply/system.txt +0 -0
  68. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  69. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  70. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  71. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  72. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  73. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/tune.py +0 -0
  74. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/verdict.py +0 -0
  75. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev79 → tuieval-0.2.0.dev86}/tests/mock_server.py +0 -0
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Start while a run is going skips models and packs already running or queued (it says which); picking only those adds nothing. Retest and picked questions still go through.
6
+ - Fix: something queued just as a run ended (while its server was stopping) now starts instead of waiting forever.
7
+ - Hosted models (OpenRouter and other APIs) run beside local ones instead of waiting for a slot: up to `parallel_hosted` at a time (default 4, `--parallel-hosted N`). Local models can be added to a hosted-only run.
5
8
  - Early stop: `tuieval run --stop-early`, **Stop a pack once it fails** in Setup, or `stop_early = true` in a pack.toml ends a pack once its FAIL is certain (a critical failure, or an accuracy or truncation bar out of reach), asking first the questions models fail most. Verdicts also say FAIL when a partial run can no longer pass.
6
9
  - `tuieval compare --reference <model>`: every other model paired against one on the questions both answered, per pack and overall: the difference with a 95% CI, better/tie/worse, critical failures and tokens vs the reference. `--packs` limits any compare to some packs.
7
10
  - Fix: a question description or grader reason containing `[` no longer crashes the run screen.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev79
3
+ Version: 0.2.0.dev86
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
@@ -71,7 +71,7 @@ The best server flags differ per model and per machine, so tuieval splits them b
71
71
 
72
72
  - **Machine id** is detected automatically (e.g. `m3max-64gb`, `m2-16gb`, or `rtx4090-128gb` on a Linux machine with a discrete GPU; set `EVALS_MACHINE` to rename it). Every result records the machine, the server version and the exact speed flags used. `tuieval machines` lists this machine and every other machine that has run the evals (they record themselves in `tuning/`).
73
73
  - **Fit check (llama):** before starting a GGUF, tuieval reads its header (layers, KV heads, hybrid attention layers) and picks the largest context that fits this machine's GPU memory (a Mac's unified memory, or a discrete GPU's VRAM), capped at `max_ctx`. A model that can't fit at 8k context is skipped with *doesn't fit on <machine>* instead of swapping. Packs that need more context than fits are skipped. It never quantizes the KV cache on its own, since that changes answers. Servers that size their own memory (their `cmd` doesn't take `{ctx}`) are left alone: their context is the model's `max_context`; `fit_check` on the server changes that.
74
- - **Per-machine settings** go under `[machines.<id>]`: `memory_headroom_gb` (GPU memory kept free: default 4 on a Mac, 1.5 in a discrete GPU's VRAM), `gpu_memory_gb` (the GPU memory to size models against, when detection gets it wrong or you want to hold some back), `gpu_residency_gb` (see the stall guard) and `parallel_models` (see below).
74
+ - **Per-machine settings** go under `[machines.<id>]`: `memory_headroom_gb` (GPU memory kept free: default 4 on a Mac, 1.5 in a discrete GPU's VRAM), `gpu_memory_gb` (the GPU memory to size models against, when detection gets it wrong or you want to hold some back), `gpu_residency_gb` (see the stall guard), `parallel_models` and `parallel_hosted` (see below).
75
75
 
76
76
  ### Discrete GPUs (Linux: Nvidia, AMD)
77
77
 
@@ -89,6 +89,7 @@ Windows isn't supported (WSL2 works like Linux).
89
89
 
90
90
  By default a run serves one model at a time. On a machine with room for more, set `parallel_models = 2` (or more) under `[machines.<id>]`, type a number in **Models at a time** on the TUI's setup screen (blank uses the machine's setting), or pass `tuieval run --parallel N`.
91
91
 
92
+ - **Hosted models** (OpenRouter and other APIs) don't use this machine, so they don't count against this: up to `parallel_hosted` of them (default 4, under `[machines.<id>]`, or `tuieval run --parallel-hosted N`) run beside the local ones, and one never waits for a local model to finish. A server is hosted when tuieval doesn't start it and its url isn't this machine; set `hosted = true` (or `false`) on a server to say otherwise. A hosted-only run doesn't hold the machine; if local models are added to it, it takes the machine when the first of them starts.
92
93
  - Models start in queue order, each in its own lane. The next one waits while its fit-check memory estimate wouldn't fit next to the ones running (without one, e.g. a server that sizes its own memory, the setting decides). Contexts are never shrunk to make room.
93
94
  - A second model on the same server gets a free port, so a server's `cmd` must take `{port}`.
94
95
  - A server with `before_start` (which may stop other servers) always runs on its own. Tuning is always one model at a time.
@@ -291,5 +292,6 @@ Optional sections:
291
292
  # memory_headroom_gb = 3 # GPU memory kept free for macOS and other apps (default 4)
292
293
  # gpu_residency_gb = 12 # GPU memory the driver keeps resident before churning (default: half of RAM)
293
294
  # parallel_models = 2 # models a run serves at a time (default 1)
295
+ # parallel_hosted = 4 # hosted models (APIs) a run serves beside them (default 4)
294
296
  ```
295
297
 
@@ -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.dev79'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev79')
21
+ __version__ = version = '0.2.0.dev86'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev86')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -47,6 +47,7 @@ import subprocess
47
47
  import threading
48
48
  import time
49
49
  import tomllib
50
+ import urllib.parse
50
51
  import urllib.request
51
52
 
52
53
  from . import client
@@ -703,6 +704,7 @@ class Engine:
703
704
  self._procs = []
704
705
  self._stop_lock = threading.Lock()
705
706
  self._run_lock = threading.Lock()
707
+ self._machine_take = threading.Lock() # one lane takes the machine lock for a hosted-only run
706
708
  self._run = None # the run going: {"jobs", "pending", "open", "machine", "cond"} (add_jobs)
707
709
  self._header_cache = {}
708
710
  self._serving = {}
@@ -1258,6 +1260,36 @@ class Engine:
1258
1260
  except (TypeError, ValueError):
1259
1261
  return 1
1260
1262
 
1263
+ def parallel_hosted(self):
1264
+ """How many hosted models (APIs) a run serves at a time, besides the local ones: models.toml
1265
+ [machines.<id>] parallel_hosted (default 4). They don't use this machine's GPU or memory."""
1266
+ try:
1267
+ return max(1, int(self.machine_settings().get("parallel_hosted", 4)))
1268
+ except (TypeError, ValueError):
1269
+ return 4
1270
+
1271
+ def models_at_once(self, labels, parallel=None, parallel_hosted=None):
1272
+ """How many of these models run at the same time: local ones up to parallel (parallel_models),
1273
+ hosted ones beside them up to parallel_hosted."""
1274
+ labels = set(labels)
1275
+ hosted = sum(self.hosted(self.model(l)) for l in labels)
1276
+ return (min(parallel or self.parallel_models(), len(labels) - hosted)
1277
+ + min(parallel_hosted or self.parallel_hosted(), hosted))
1278
+
1279
+ def hosted(self, m):
1280
+ """A model served by an API elsewhere: tuieval doesn't start its server and its url isn't this
1281
+ machine (or its server sets hosted = true). A server you started yourself on localhost still
1282
+ counts as local (it uses the GPU)."""
1283
+ if m.get("remote"):
1284
+ return True
1285
+ server = self.cfg["servers"][m["server"]]
1286
+ if "hosted" in server: # say so for a server the url doesn't give away (e.g. a local proxy to an API)
1287
+ return bool(server["hosted"])
1288
+ if server.get("cmd"):
1289
+ return False
1290
+ host = urllib.parse.urlparse(server.get("url") or "").hostname or ""
1291
+ return bool(host) and host not in ("localhost", "127.0.0.1", "::1", "0.0.0.0")
1292
+
1261
1293
  def memory_need_gb(self, m):
1262
1294
  """GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
1263
1295
  server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
@@ -1616,17 +1648,17 @@ class Engine:
1616
1648
  return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
1617
1649
 
1618
1650
  # ---- running
1619
- def run(self, jobs, parallel=None):
1651
+ def run(self, jobs, parallel=None, parallel_hosted=None):
1620
1652
  """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1621
- models are served at a time (default: parallel_models for this machine). add_jobs() can add
1622
- more while it runs; they run after the ones before them."""
1653
+ local models are served at a time (default: parallel_models for this machine); hosted models
1654
+ (APIs) run beside them, up to parallel_hosted at a time (default: parallel_hosted). add_jobs()
1655
+ can add more while it runs; they run after the ones before them."""
1623
1656
  self._cancel.clear()
1624
1657
  self.emit("queue_started", jobs=jobs)
1625
1658
  models = sorted({j.label for j in jobs if j.status == "waiting"})
1626
1659
  machine = bool(jobs) and self.needs_machine(jobs)
1627
- lock = (self.machine_lock(f"{jobs[0].tier} run: {', '.join(models[:3])}"
1628
- + (f" +{len(models) - 3}" if len(models) > 3 else ""))
1629
- if machine else contextlib.nullcontext())
1660
+ what = f"{jobs[0].tier} run: {', '.join(models[:3])}" + (f" +{len(models) - 3}" if len(models) > 3 else "")
1661
+ stack = contextlib.ExitStack() # holds the machine lock; a hosted-only run takes it if local models are added
1630
1662
  order = []
1631
1663
  for j in jobs:
1632
1664
  if j.label not in order:
@@ -1634,10 +1666,13 @@ class Engine:
1634
1666
  groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1635
1667
  with self._run_lock:
1636
1668
  self._run = {"jobs": jobs, "pending": [g for g in groups if g[1]], "open": True, "machine": machine,
1637
- "cond": threading.Condition(self._run_lock)}
1669
+ "cond": threading.Condition(self._run_lock), "stack": stack, "what": what}
1638
1670
  try:
1639
- with lock:
1640
- self._run_lanes(max(1, int(parallel or self.parallel_models())))
1671
+ with stack:
1672
+ if machine:
1673
+ stack.enter_context(self.machine_lock(what))
1674
+ self._run_lanes(max(1, int(parallel or self.parallel_models())),
1675
+ max(1, int(parallel_hosted or self.parallel_hosted())))
1641
1676
  if self._cancel.is_set():
1642
1677
  raise Cancelled()
1643
1678
  except Cancelled:
@@ -1664,14 +1699,13 @@ class Engine:
1664
1699
  def add_jobs(self, new):
1665
1700
  """Add planned jobs to the run going on, after its other jobs. Returns (added, why not): a
1666
1701
  pack already waiting or running in it isn't added twice, and nothing is added once the run is
1667
- finishing or cancelled, or when it holds no machine lock (hosted only) and these need one.
1702
+ finishing or cancelled. Local models added to a hosted-only run take the machine lock when the
1703
+ first of them starts.
1668
1704
  Runs on the caller's thread, so the caller shows what was added (no event)."""
1669
1705
  with self._run_lock:
1670
1706
  r = self._run
1671
1707
  if not r or not r["open"] or self._cancel.is_set():
1672
1708
  return [], "the run is finishing"
1673
- if not r["machine"] and self.needs_machine(new):
1674
- return [], "the run going uses only hosted models, and these need this machine"
1675
1709
  busy = {j.key for j in r["jobs"] if j.status in ("waiting", "loading", "running")}
1676
1710
  added = [j for j in new if j.status == "waiting" and j.key not in busy]
1677
1711
  r["jobs"].extend(added)
@@ -1684,33 +1718,51 @@ class Engine:
1684
1718
  r["cond"].notify_all()
1685
1719
  return added, None if added else "everything picked is already in the run"
1686
1720
 
1687
- def _run_lanes(self, parallel):
1688
- """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1689
- model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1690
- next to the ones running (memory_need_gb; an unknown need is left to the setting). Lanes
1691
- wait for jobs added meanwhile (add_jobs) until every model is done."""
1721
+ def _run_lanes(self, parallel, parallel_hosted=4):
1722
+ """Run each model's jobs in its own lane: up to `parallel` local models at a time, in order (the
1723
+ next one starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1724
+ next to the ones running (memory_need_gb; an unknown need is left to the setting)), and beside
1725
+ them up to `parallel_hosted` hosted models, which need none of this machine. Lanes wait for jobs
1726
+ added meanwhile (add_jobs) until every model is done."""
1692
1727
  pending, running, told = self._run["pending"], {}, set()
1693
1728
  cond = self._run["cond"]
1729
+ hosted = {}
1730
+
1731
+ def is_hosted(label):
1732
+ if label not in hosted:
1733
+ hosted[label] = self.hosted(self.model(label))
1734
+ return hosted[label]
1694
1735
 
1695
1736
  def blocked(label):
1696
1737
  """Why the next model can't start now (None: it can)."""
1697
- if not running:
1738
+ if is_hosted(label):
1739
+ return "" if sum(map(is_hosted, running)) >= parallel_hosted else None
1740
+ local = [l for l in running if not is_hosted(l)]
1741
+ if not local:
1698
1742
  return None
1699
- if len(running) >= parallel:
1743
+ if len(local) >= parallel:
1700
1744
  return ""
1701
1745
  m = self.model(label)
1702
- if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
1703
- alone = label if self.runs_alone(m) else next(l for l in running if self.runs_alone(self.model(l)))
1746
+ if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in local):
1747
+ alone = label if self.runs_alone(m) else next(l for l in local if self.runs_alone(self.model(l)))
1704
1748
  return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
1705
1749
  "on its own")
1706
1750
  need = self.memory_need_gb(m)
1707
- used = sum(n for n in running.values() if n)
1751
+ used = sum(running[l] or 0 for l in local)
1708
1752
  room = self.memory_available_gb()
1709
1753
  if need and used + need > room:
1710
- return (f"needs ~{need:.0f} GB; {', '.join(running)} take ~{used:.0f} of {room:.0f} GB, so it "
1754
+ return (f"needs ~{need:.0f} GB; {', '.join(local)} take ~{used:.0f} of {room:.0f} GB, so it "
1711
1755
  "starts when there's room")
1712
1756
  return None
1713
1757
 
1758
+ def take_machine():
1759
+ """A hosted-only run that was given local models takes the machine lock before the first
1760
+ of them starts (it may wait for another tuieval window, like any run)."""
1761
+ with self._machine_take:
1762
+ if not self._run["machine"]:
1763
+ self._run["stack"].enter_context(self.machine_lock(self._run["what"]))
1764
+ self._run["machine"] = True
1765
+
1714
1766
  def lane():
1715
1767
  while True:
1716
1768
  mine, note = None, None
@@ -1720,33 +1772,41 @@ class Engine:
1720
1772
  cond.notify_all()
1721
1773
  return
1722
1774
  # in order, except that a model added again while it runs waits for itself to end
1723
- # without holding up the ones behind it
1724
- nxt = next((i for i, g in enumerate(pending) if g[0] not in running), None)
1725
- label = pending[nxt][0] if nxt is not None else None
1726
- why = blocked(label) if label else "" # none can start: a model still runs, more may come
1727
- if why is None:
1728
- label, mine = pending.pop(nxt)
1729
- running[label] = self.memory_need_gb(self.model(label))
1730
- elif why and label not in told:
1731
- told.add(label)
1732
- note = f"{label} waits: {why}"
1733
- else:
1734
- cond.wait(1.0)
1775
+ # without holding up the ones behind it; the first waiting hosted model doesn't wait
1776
+ # for local ones (and the first local one doesn't wait for hosted ones)
1777
+ waiting = [i for i, g in enumerate(pending) if g[0] not in running]
1778
+ first = {}
1779
+ for i in waiting:
1780
+ first.setdefault(is_hosted(pending[i][0]), i)
1781
+ why, label = "", None
1782
+ for i in sorted(first.values()):
1783
+ why = blocked(pending[i][0])
1784
+ if why is None:
1785
+ label, mine = pending.pop(i)
1786
+ running[label] = self.memory_need_gb(self.model(label))
1787
+ break
1788
+ if mine is None:
1789
+ label = pending[min(first.values())][0] if first else None
1790
+ if why and label and label not in told:
1791
+ told.add(label)
1792
+ note = f"{label} waits: {why}"
1793
+ else:
1794
+ cond.wait(1.0)
1735
1795
  if note:
1736
1796
  self.emit("model_waiting", label=label, message=note)
1737
1797
  if mine is None:
1738
1798
  continue
1739
1799
  try:
1800
+ if not is_hosted(label) and self.cfg["servers"][mine[0].model["server"]].get("cmd"):
1801
+ take_machine()
1740
1802
  self._run_lane(label, mine)
1741
1803
  finally:
1742
1804
  with cond:
1743
1805
  running.pop(label, None)
1744
1806
  cond.notify_all()
1745
1807
 
1746
- if parallel == 1:
1747
- lane()
1748
- return
1749
- threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel)]
1808
+ # lanes for hosted models too, even if none is queued yet: add_jobs may bring some
1809
+ threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel + parallel_hosted)]
1750
1810
  for t in threads:
1751
1811
  t.start()
1752
1812
  for t in threads:
@@ -17,6 +17,7 @@
17
17
  tuieval run --tier smoke --only openrouter:qwen/qwen3-32b # any OpenRouter model, no models.toml edit
18
18
  tuieval run --dry-run # print the plan and server commands only
19
19
  tuieval run --parallel 2 # serve two models at a time (parallel_models in models.toml)
20
+ tuieval run --parallel-hosted 6 # hosted models at a time beside them (default 4)
20
21
  tuieval add ~/models/New-Model-Q4_K_M.gguf # register a model (--vision/--mmproj, --no-think, --tags)
21
22
  tuieval scan # GGUFs in model_dirs that aren't registered yet
22
23
  tuieval list # the models tuieval knows; hidden ones listed separately
@@ -755,6 +756,9 @@ def main(argv=None):
755
756
  p.add_argument("--smoke", action="store_const", const="smoke", dest="tier", help="same as --tier smoke")
756
757
  p.add_argument("--parallel", type=int, metavar="N",
757
758
  help="models served at a time (default: parallel_models for this machine in models.toml, else 1)")
759
+ p.add_argument("--parallel-hosted", type=int, metavar="N",
760
+ help="hosted models (APIs) served at a time beside the local ones (default: parallel_hosted "
761
+ "in models.toml, else 4)")
758
762
  p.add_argument("--dry-run", action="store_true", help="print the plan and commands without running")
759
763
  p.add_argument("--brief", action="store_true", help="end with one line per model instead of the scorecards")
760
764
  p.add_argument("--models", default=None, help="default: the workspace's models.toml")
@@ -769,6 +773,9 @@ def main(argv=None):
769
773
  if args.parallel is not None and args.parallel < 1:
770
774
  sys.exit("--parallel must be 1 or more")
771
775
  parallel = args.parallel or e.parallel_models()
776
+ if args.parallel_hosted is not None and args.parallel_hosted < 1:
777
+ sys.exit("--parallel-hosted must be 1 or more")
778
+ parallel_hosted = args.parallel_hosted or e.parallel_hosted()
772
779
  models = e.cfg["models"]
773
780
  labels = [m["label"] for m in models if not m.get("remote")] # hosted APIs cost money: only by name
774
781
  if args.only:
@@ -837,12 +844,12 @@ def main(argv=None):
837
844
 
838
845
  signal.signal(signal.SIGINT, stop)
839
846
  signal.signal(signal.SIGTERM, stop)
840
- side_by_side = min(parallel, len({j.label for j in jobs if j.status == "waiting"}))
847
+ side_by_side = e.models_at_once({j.label for j in jobs if j.status == "waiting"}, parallel, parallel_hosted)
841
848
  if side_by_side > 1:
842
849
  printer.side_by_side = True
843
850
  say(f"up to {side_by_side} models at a time: one line per answer; each answer notes the models "
844
851
  "it ran alongside, since they share this machine's speed")
845
- worker = threading.Thread(target=e.run, args=(jobs, parallel))
852
+ worker = threading.Thread(target=e.run, args=(jobs, parallel, parallel_hosted))
846
853
  worker.start()
847
854
  while worker.is_alive():
848
855
  worker.join(0.5)
@@ -975,7 +975,7 @@ class SetupScreen(Screen):
975
975
  resumed = [j for j in run if "done earlier" in j.note]
976
976
  if resumed:
977
977
  text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
978
- at_once = min(self.parallel() or e.parallel_models(), len({j.label for j in run}))
978
+ at_once = e.models_at_once({j.label for j in run}, self.parallel())
979
979
  if at_once > 1:
980
980
  text += (f"\n[cyan]Up to {at_once} models at a time[/cyan][dim] (the time above assumes one at a "
981
981
  "time). Answers note the models they ran alongside, since those share the machine's "
@@ -1550,6 +1550,13 @@ def run_title(tier, models, packs):
1550
1550
  f"{', '.join(packs) if len(packs) <= 2 else f'{len(packs)} packs'}")
1551
1551
 
1552
1552
 
1553
+ def short_keys(keys, n=4):
1554
+ """A few "model/pack" pairs as "model · pack", the rest counted."""
1555
+ keys = sorted(keys)
1556
+ shown = ", ".join(k.replace("/", " · ", 1) for k in keys[:n])
1557
+ return rich_escape(shown + (f" and {len(keys) - n} more" if len(keys) > n else ""))
1558
+
1559
+
1553
1560
  class QueuedRun:
1554
1561
  """A run waiting its turn. It's planned again when it starts, so it picks up what the runs before
1555
1562
  it finished; it never sets aside answers that weren't confirmed when it was queued. retest: the
@@ -1557,13 +1564,29 @@ class QueuedRun:
1557
1564
  kind = "Run"
1558
1565
 
1559
1566
  def __init__(self, sel, retest, leave, accepted, eng):
1560
- self.sel, self.retest, self.leave, self.accepted = sel, retest, leave, accepted
1567
+ self.sel, self.retest, self.leave, self.accepted = sel, retest, set(leave), accepted
1561
1568
  self.queued = time.time()
1569
+ self.replan(eng)
1570
+
1571
+ def replan(self, eng):
1562
1572
  self.jobs, self.problem = self.plan(eng)
1563
1573
  jobs = self.jobs or []
1564
- self.title = run_title(sel["tier"], {j.label for j in jobs}, {j.pack.label for j in jobs})
1574
+ self.title = run_title(self.sel["tier"], {j.label for j in jobs}, {j.pack.label for j in jobs})
1565
1575
  self.est = eng.estimate_seconds(jobs)[0]
1566
1576
 
1577
+ def keys(self):
1578
+ """The "model/pack" pairs it covers."""
1579
+ return {f"{m}/{p}" for m in self.sel["models"] for p in self.sel["packs"]} - self.leave
1580
+
1581
+ def deliberate(self):
1582
+ """Retest or picked questions: asked again on purpose, never treated as a duplicate."""
1583
+ return bool(self.sel.get("force") or self.sel.get("tests"))
1584
+
1585
+ def drop(self, keys, eng):
1586
+ """Leave these pairs out (they're already running or queued)."""
1587
+ self.leave |= set(keys)
1588
+ self.replan(eng)
1589
+
1567
1590
  def tests(self, eng):
1568
1591
  """The picked tests that are still in their packs (a pack edited while queued drops the rest)."""
1569
1592
  out = {}
@@ -2130,8 +2153,7 @@ class RunScreen(Screen):
2130
2153
  @property
2131
2154
  def side_by_side(self):
2132
2155
  """Several models run at a time in this run."""
2133
- return min(self.parallel or self.app.engine.parallel_models(),
2134
- len({j.label for j in self.jobs if j.status != "skipped"})) > 1
2156
+ return self.app.engine.models_at_once({j.label for j in self.jobs if j.status != "skipped"}, self.parallel) > 1
2135
2157
 
2136
2158
  def active(self):
2137
2159
  """Models being loaded or run now, in queue order."""
@@ -2446,7 +2468,7 @@ class RunScreen(Screen):
2446
2468
  rough |= j.key in self.rough
2447
2469
  left += (j.total - j.done) * spr
2448
2470
  models.add(j.label)
2449
- lanes = max(1, min(self.parallel or self.app.engine.parallel_models(), len(models)))
2471
+ lanes = max(1, self.app.engine.models_at_once(models, self.parallel))
2450
2472
  return left / lanes, rough
2451
2473
 
2452
2474
  def update_overall(self):
@@ -3237,8 +3259,32 @@ class EvalsApp(App):
3237
3259
  what = s.live_text() if s else "The last run is still stopping its server"
3238
3260
  return what + (f" (+{len(self.queue)} queued)" if self.queue else "")
3239
3261
 
3262
+ def taken(self, tier):
3263
+ """The "model/pack" pairs of this tier already in the run going (not finished) or in the queue."""
3264
+ out = set()
3265
+ run = self.active_session
3266
+ if isinstance(run, RunScreen) and run.running and run.jobs and run.jobs[0].tier == tier:
3267
+ out |= {j.key for j in run.jobs if j.status in ("waiting", "loading", "running")}
3268
+ for q in self.queue:
3269
+ if q.kind == "Run" and q.sel["tier"] == tier:
3270
+ out |= q.keys()
3271
+ return out
3272
+
3240
3273
  # -- the queue: Start or t while something runs adds to it; each item starts when the one before ends
3241
3274
  def start_or_queue(self, item):
3275
+ skipped = set()
3276
+ if self.is_busy() and item.kind == "Run" and not item.deliberate():
3277
+ mine = item.keys()
3278
+ skipped = mine & self.taken(item.sel["tier"])
3279
+ if skipped == mine:
3280
+ self.notify(f"Already running or queued, so nothing was added: {short_keys(skipped)}.", timeout=10)
3281
+ return
3282
+ if skipped:
3283
+ item.drop(skipped, self.engine)
3284
+ if not item.jobs:
3285
+ self.notify(f"Nothing new to run: {item.problem}. Already running or queued: "
3286
+ f"{short_keys(skipped)}.", timeout=10)
3287
+ return
3242
3288
  if not self.is_busy():
3243
3289
  screen, problem = item.make(self.engine)
3244
3290
  if problem:
@@ -3257,11 +3303,11 @@ class EvalsApp(App):
3257
3303
  self.add_to_run(run, item)
3258
3304
  elif choice == "queue":
3259
3305
  self.enqueue(item)
3260
- what = f"{', '.join(item.sel['models'][:4])}{' …' if len(item.sel['models']) > 4 else ''} · " \
3261
- f"{', '.join(item.sel['packs'][:4])}{' …' if len(item.sel['packs']) > 4 else ''}"
3306
+ what = short_keys(item.keys(), 6)
3307
+ skip = (f"\n\nLeft out, already running or queued: {short_keys(skipped)}." if skipped else "")
3262
3308
  self.push_screen(ChoiceScreen(
3263
3309
  f"A run is going: {run.live_text()}.\n\nAdd {what} to it? Its models run after the ones "
3264
- "already in it.\nOr queue it as a separate run that starts when this one ends.",
3310
+ f"already in it.\nOr queue it as a separate run that starts when this one ends.{skip}",
3265
3311
  [("add", "Add to this run", "success"), ("queue", f"Queue it as #{len(self.queue) + 1}", "primary"),
3266
3312
  ("keep", "Cancel", "default")]), picked)
3267
3313
  return
@@ -3271,6 +3317,9 @@ class EvalsApp(App):
3271
3317
  jobs, problem = item.plan(self.engine)
3272
3318
  added, why = self.engine.add_jobs(jobs) if jobs else ([], problem)
3273
3319
  if not added:
3320
+ if why and why.startswith("everything picked is already in the run"):
3321
+ self.notify(f"Not added: {why}.", timeout=10) # queueing it again would only duplicate it
3322
+ return
3274
3323
  self.notify(f"Not added: {why}. Queued it instead.", severity="warning", timeout=10)
3275
3324
  self.enqueue(item)
3276
3325
  return
@@ -3282,6 +3331,8 @@ class EvalsApp(App):
3282
3331
  def enqueue(self, item):
3283
3332
  ahead = self.busy_text()
3284
3333
  self.queue.append(item)
3334
+ if not self.active_session: # the last run already ended (its server may still be stopping):
3335
+ self.set_timer(0.3, self.start_next) # nothing else would start the queue
3285
3336
  self.notify(f"Queued #{len(self.queue)}: {item.title}. It starts by itself after: {ahead}. "
3286
3337
  "w shows the queue.", timeout=10)
3287
3338
  self.refresh_live()
@@ -15,6 +15,7 @@ import threading
15
15
  import types
16
16
  import time
17
17
  import unittest
18
+ import unittest.mock
18
19
  import urllib.request
19
20
 
20
21
  HERE = os.path.dirname(os.path.abspath(__file__))
@@ -283,6 +284,11 @@ class Units(unittest.TestCase):
283
284
  job = lambda key, label, total, done=0, status="waiting": types.SimpleNamespace(
284
285
  key=key, label=label, total=total, done=done, status=status)
285
286
  run = RunScreen.__new__(RunScreen)
287
+ eng = types.SimpleNamespace(models_at_once=lambda labels, parallel: min(parallel, len(labels)))
288
+ patch = unittest.mock.patch.object(RunScreen, "app", new_callable=unittest.mock.PropertyMock,
289
+ return_value=types.SimpleNamespace(engine=eng))
290
+ patch.start()
291
+ self.addCleanup(patch.stop)
286
292
  run.parallel, run.rough = 1, {"local/new"}
287
293
  run.jobs = [job("hosted/a", "hosted", 100, 100, "done"), job("local/a", "local", 200, 4, "running"),
288
294
  job("local/new", "local", 50)]
@@ -854,6 +860,66 @@ class MarkupInText(unittest.TestCase):
854
860
  self.assertEqual(json.loads(p.stdout.strip().splitlines()[-1]), [["m/br", "done", 2]], p.stderr[-2000:])
855
861
 
856
862
 
863
+ class HostedBeside(unittest.TestCase):
864
+ """Hosted models (APIs) don't use this machine: they run beside local ones whatever parallel_models
865
+ says, and local models can join a hosted-only run."""
866
+
867
+ def setUp(self):
868
+ self.tmp = tempfile.TemporaryDirectory()
869
+ self.ws = os.path.join(self.tmp.name, "ws")
870
+ tuieval(self.tmp.name, "init", self.ws)
871
+ d = os.path.join(self.ws, "packs", "apps")
872
+ os.makedirs(d)
873
+ write(os.path.join(d, "pack.toml"), 'label = "apps"\n[certify]\nrepeat = 1\n[gate]\nmin_accuracy = 0.1\n')
874
+ write(os.path.join(d, "tests.yaml"), "".join(
875
+ f"- {{id: {t}, input: question {t}, expected: 1, reference: 'ANSWER: 1', difficulty: easy}}\n" for t in "abcd"))
876
+ local = [sys.executable, os.path.join(HERE, "mock_server.py"), "--port", "{port}", "--packs",
877
+ os.path.join(self.ws, "packs"), "--model", "{served_name}", "--delay", "0.5"]
878
+ self.api = Mock(os.path.join(self.ws, "packs"), "oracle", delay=0.5)
879
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
880
+ f.write(f'\n[servers.mk]\ncmd = {json.dumps(local)}\nport = {free_port()}\n'
881
+ f'\n[servers.api]\nurl = "http://127.0.0.1:{self.api.port}"\nhosted = true\n'
882
+ '\n[[models]]\nlabel = "loc"\nserver = "mk"\nmodel = "loc"\n')
883
+ for label in ("h1", "h2"):
884
+ f.write(f'\n[[models]]\nlabel = "{label}"\nserver = "api"\nmodel = "mock"\n')
885
+
886
+ def tearDown(self):
887
+ self.api.stop()
888
+ self.tmp.cleanup()
889
+
890
+ def results(self, label):
891
+ return json.loads(read(os.path.join(self.ws, "results", label, "apps.json")))["results"]
892
+
893
+ def test_hosted_run_beside_local_with_one_slot(self):
894
+ from tuieval import engine
895
+ e = engine.Engine(lambda kind, **d: None, root=self.ws)
896
+ self.assertEqual([e.hosted(e.model(l)) for l in ("loc", "h1", "h2")], [False, True, True])
897
+ self.assertEqual(e.models_at_once({"loc", "h1", "h2"}, 1, 4), 3)
898
+ jobs = e.plan(["loc", "h1", "h2"], ["apps"], None, "certify", False, {})
899
+ e.run(jobs, parallel=1)
900
+ self.assertEqual([j.status for j in jobs], ["done"] * 3, [(j.key, j.note) for j in jobs])
901
+ seen = {l: set().union(*(r.get("ran_alongside", []) for r in self.results(l))) for l in ("loc", "h1", "h2")}
902
+ self.assertEqual(seen["loc"], {"h1", "h2"}) # neither hosted model waited for the local one
903
+ self.assertIn("h2", seen["h1"])
904
+
905
+ def test_local_model_joins_a_hosted_only_run(self):
906
+ from tuieval import engine
907
+ events = []
908
+ e = engine.Engine(lambda kind, **d: events.append(kind), root=self.ws)
909
+ jobs = e.plan(["h1"], ["apps"], None, "certify", False, {})
910
+ t = threading.Thread(target=e.run, args=(jobs,))
911
+ t.start()
912
+ deadline = time.time() + 120
913
+ while "request_done" not in events and time.time() < deadline:
914
+ time.sleep(0.05)
915
+ added, why = e.add_jobs(e.plan(["loc"], ["apps"], None, "certify", False, {}))
916
+ self.assertEqual(([j.key for j in added], why), (["loc/apps"], None))
917
+ t.join(300)
918
+ self.assertFalse(t.is_alive())
919
+ self.assertEqual(len(self.results("loc")), 4)
920
+ self.assertIsNone(e.machine_holder()) # the lock it took is released again
921
+
922
+
857
923
  class AddToRun(unittest.TestCase):
858
924
  """More evals while a run is going: added to it (they run after its other jobs) or queued after it."""
859
925
 
@@ -941,6 +1007,10 @@ class AddToRun(unittest.TestCase):
941
1007
  import asyncio, json, time
942
1008
  from tuieval.tui import EvalsApp, RunScreen, SetupScreen, ChoiceScreen
943
1009
  from textual.widgets import SelectionList
1010
+ async def setup(app, pilot): # a dialog closing can take a moment on a slow machine
1011
+ deadline = time.time() + 30
1012
+ while not isinstance(app.screen, SetupScreen) and time.time() < deadline:
1013
+ await pilot.pause(0.1)
944
1014
  async def go():
945
1015
  app = EvalsApp({})
946
1016
  async with app.run_test(size=(160, 50)) as pilot:
@@ -967,7 +1037,7 @@ class AddToRun(unittest.TestCase):
967
1037
  await pilot.pause()
968
1038
  asked = isinstance(app.screen, ChoiceScreen)
969
1039
  app.screen.dismiss("add")
970
- await pilot.pause()
1040
+ await setup(app, pilot)
971
1041
  app.screen.query_one("#suites", SelectionList).deselect("apps")
972
1042
  app.screen.query_one("#suites", SelectionList).select("other")
973
1043
  app.screen.selected_models = {"a"}
@@ -975,7 +1045,7 @@ class AddToRun(unittest.TestCase):
975
1045
  app.screen.action_start()
976
1046
  await pilot.pause()
977
1047
  app.screen.dismiss("queue")
978
- await pilot.pause()
1048
+ await setup(app, pilot)
979
1049
  app.screen.query_one("#tier-smoke").value = True # another tier can't join: queued
980
1050
  await pilot.pause()
981
1051
  app.screen.action_start()
@@ -1008,6 +1078,76 @@ class AddToRun(unittest.TestCase):
1008
1078
  self.assertEqual(out["second"], [["a/other", "done"]]) # queued: its own run after
1009
1079
 
1010
1080
 
1081
+ def test_tui_never_queues_the_same_thing_twice(self):
1082
+ """Start while a run is going never adds or queues a model and pack already running or queued."""
1083
+ code = textwrap.dedent("""
1084
+ import asyncio, json, time
1085
+ from tuieval.tui import EvalsApp, RunScreen, SetupScreen, ChoiceScreen
1086
+ from textual.widgets import SelectionList
1087
+ async def pick(app, pilot, models, packs=("apps",)):
1088
+ deadline = time.time() + 30
1089
+ while not isinstance(app.screen, SetupScreen) and time.time() < deadline:
1090
+ await pilot.pause(0.1)
1091
+ s = app.screen
1092
+ for p in ("apps", "other"):
1093
+ (s.query_one("#suites", SelectionList).select if p in packs
1094
+ else s.query_one("#suites", SelectionList).deselect)(p)
1095
+ s.selected_models = set(models)
1096
+ s.refresh_models()
1097
+ await pilot.pause()
1098
+ s.action_start()
1099
+ await pilot.pause()
1100
+ return app.screen
1101
+ async def go():
1102
+ app = EvalsApp({})
1103
+ async with app.run_test(size=(160, 50)) as pilot:
1104
+ await pilot.pause()
1105
+ app.screen.query_one("#tier-certify").value = True
1106
+ await pick(app, pilot, ["a"])
1107
+ deadline = time.time() + 180
1108
+ while time.time() < deadline:
1109
+ await pilot.pause(0.1)
1110
+ if isinstance(app.screen, RunScreen) and app.screen.counts()[0]:
1111
+ break
1112
+ run = app.screen
1113
+ await pilot.press("n")
1114
+ out = {}
1115
+ s = await pick(app, pilot, ["a"]) # the same again: nothing happens
1116
+ out["same"] = [isinstance(s, ChoiceScreen), len(app.queue)]
1117
+ s = await pick(app, pilot, ["a", "b"]) # partly new: only b is offered
1118
+ out["partly"] = [isinstance(s, ChoiceScreen), "Left out" in str(getattr(s, "question", ""))]
1119
+ s.dismiss("queue")
1120
+ await pilot.pause()
1121
+ out["queued"] = [sorted(q.keys()) for q in app.queue]
1122
+ s = await pick(app, pilot, ["b"]) # already queued: nothing happens
1123
+ out["again"] = [isinstance(s, ChoiceScreen), len(app.queue)]
1124
+ s = await pick(app, pilot, ["a"], ("other",)) # new: offered, queued or started
1125
+ out["new"] = (isinstance(s, ChoiceScreen) or any("a/other" in q.keys() for q in app.queue)
1126
+ or any(j.key == "a/other" for x in app.sessions for j in x.jobs))
1127
+ if isinstance(s, ChoiceScreen):
1128
+ s.dismiss("queue")
1129
+ await pilot.pause()
1130
+ deadline = time.time() + 300 # the queued runs start one after another: wait for all three
1131
+ while time.time() < deadline:
1132
+ await pilot.pause(0.2)
1133
+ if len(app.sessions) == 3 and not app.queue and not any(x.running for x in app.sessions):
1134
+ break
1135
+ out["sessions"] = sorted((j.key, j.status) for x in app.sessions for j in x.jobs)
1136
+ print(json.dumps(out))
1137
+ asyncio.run(go())
1138
+ """)
1139
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1140
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
1141
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1142
+ self.assertIsInstance(out, dict, out)
1143
+ self.assertEqual(out["same"], [False, 0])
1144
+ self.assertEqual(out["partly"], [True, True])
1145
+ self.assertEqual(out["queued"], [["b/apps"]])
1146
+ self.assertEqual(out["again"], [False, 1])
1147
+ self.assertTrue(out["new"])
1148
+ self.assertEqual(out["sessions"], [["a/apps", "done"], ["a/other", "done"], ["b/apps", "done"]])
1149
+
1150
+
1011
1151
  class PTAIndex(unittest.TestCase):
1012
1152
  """The PTA index: parsimony from tokens and speed from time, each relative to the best on a log
1013
1153
  scale, and accuracy, all over the questions every compared model answered."""
File without changes
File without changes
File without changes