tuieval 0.2.0.dev77__tar.gz → 0.2.0.dev81__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.dev77 → tuieval-0.2.0.dev81}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/models.md +3 -1
  4. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/writing-packs.md +3 -0
  5. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/_version.py +2 -2
  6. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/engine.py +144 -45
  7. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/packs.py +3 -1
  8. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/run_evals.py +13 -3
  9. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/tui.py +14 -7
  10. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/verdict.py +23 -0
  11. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/tests/test_tuieval.py +112 -0
  12. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/.gitignore +0 -0
  13. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/LICENSE +0 -0
  14. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/README.md +0 -0
  15. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/RELEASING.md +0 -0
  16. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/README.md +0 -0
  17. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/favicon.ico +0 -0
  18. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/readme-header.png +0 -0
  19. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/social-preview.png +0 -0
  20. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-1024.png +0 -0
  21. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-128.png +0 -0
  22. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-16.png +0 -0
  23. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-16.svg +0 -0
  24. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-256.png +0 -0
  25. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-32.png +0 -0
  26. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-48.png +0 -0
  27. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-512.png +0 -0
  28. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-64.png +0 -0
  29. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  30. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon.svg +0 -0
  31. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/tui-answer-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/tui-run-reasoning.png +0 -0
  33. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/tui-setup.png +0 -0
  34. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/builtin.toml +0 -0
  38. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/cli.py +0 -0
  39. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/client.py +0 -0
  40. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/compare.py +0 -0
  41. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/doctor.py +0 -0
  42. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/export.py +0 -0
  43. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/__init__.py +0 -0
  44. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/answer.py +0 -0
  45. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/code.py +0 -0
  46. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/rag.py +0 -0
  47. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/reply.py +0 -0
  48. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/tool_call.py +0 -0
  49. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/logs.py +0 -0
  50. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/machines.py +0 -0
  51. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/profiles.py +0 -0
  53. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/pta.py +0 -0
  54. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - 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.
6
+ - 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.
5
7
  - `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.
6
8
  - Fix: a question description or grader reason containing `[` no longer crashes the run screen.
7
9
  - Retest asks per pack before it starts: from zero, continue or leave out. It now really starts from zero, and replaced smoke results go to history/.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev77
3
+ Version: 0.2.0.dev81
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
 
@@ -34,6 +34,7 @@ needs = ["vision"] # vision | tools | long_context | <python modul
34
34
  order = 30 # position in the list
35
35
  tests = ["tests.yaml"] # optional; default: tests.* first, then every other .yaml/.csv file
36
36
  screen = 20 # tests in a Screen run (spread across categories, one variant per group)
37
+ stop_early = true # stop a run of this pack once its FAIL is certain (default false; see below)
37
38
 
38
39
  [certify]
39
40
  repeat = 3 # repeats in a Certify run (default: models.toml [defaults] repeat)
@@ -48,6 +49,8 @@ min_consistency = 0.95 # share of variant groups where every variant p
48
49
 
49
50
  `group` doubles as the **use case** in the readiness verdicts: all packs in a group must PASS for the use case to PASS. For example, a "Coding" use case could be a `coding` pack plus a `coding-sql` pack.
50
51
 
52
+ `stop_early` suits packs a weak model fails quickly, like a regression set of routine cases: a run stops the pack at the first critical failure, or once the accuracy or truncation bar is out of reach even if every remaining answer passes, saves what it has (the verdict is FAIL) and moves on. It asks first the questions models have failed most often. `tuieval run --stop-early` (or **Stop a pack once it fails** in Setup) does this for every pack in the run. A later Certify fills in the rest.
53
+
51
54
  `needs` entries other than `vision`, `tools` and `long_context` name Python modules the pack's grading needs (for example `pandas`, when your hidden tests use it). The pack is skipped, with a note, where that module isn't installed in tuieval's Python environment.
52
55
 
53
56
  ## Tests (YAML)
@@ -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.dev77'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev77')
21
+ __version__ = version = '0.2.0.dev81'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev81')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -28,6 +28,7 @@ interrupted run resumes from its .partial.jsonl, request by request.
28
28
  Events are delivered as on_event(kind, **data), from the engine thread.
29
29
  """
30
30
  import base64
31
+ import collections
31
32
  import contextlib
32
33
  import dataclasses
33
34
  import fcntl
@@ -46,6 +47,7 @@ import subprocess
46
47
  import threading
47
48
  import time
48
49
  import tomllib
50
+ import urllib.parse
49
51
  import urllib.request
50
52
 
51
53
  from . import client
@@ -438,6 +440,8 @@ class Job:
438
440
  finished: float = 0.0
439
441
  resumed: int = 0 # answers already done when this sitting started
440
442
  earlier: list = dataclasses.field(default_factory=list) # earlier sittings (see Engine._sittings)
443
+ stop_early: bool = False # stop once the pack's FAIL is certain (verdict.fail_certain)
444
+ first: list = dataclasses.field(default_factory=list) # test ids round 1 asks first (most failed)
441
445
 
442
446
  @property
443
447
  def label(self):
@@ -700,6 +704,7 @@ class Engine:
700
704
  self._procs = []
701
705
  self._stop_lock = threading.Lock()
702
706
  self._run_lock = threading.Lock()
707
+ self._machine_take = threading.Lock() # one lane takes the machine lock for a hosted-only run
703
708
  self._run = None # the run going: {"jobs", "pending", "open", "machine", "cond"} (add_jobs)
704
709
  self._header_cache = {}
705
710
  self._serving = {}
@@ -1152,14 +1157,15 @@ class Engine:
1152
1157
  return not current and header_matches(head, pack.fingerprint, settings)
1153
1158
 
1154
1159
  # ---- planning
1155
- def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None):
1160
+ def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None, stop_early=None):
1156
1161
  """Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
1157
1162
  tests). repeat: times each question is asked; None = the tier default (1 for smoke and
1158
1163
  screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
1159
1164
  those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
1160
1165
  matching results are kept and only missing answers are run, unless force; force with
1161
1166
  picked tests reruns those and keeps the pack's other answers. force is True, False, or the
1162
- keys ("label/pack") of the jobs to retest, the others resuming."""
1167
+ keys ("label/pack") of the jobs to retest, the others resuming. stop_early: stop each pack
1168
+ once its FAIL is certain, asking first what models fail most; None = each pack's stop_early."""
1163
1169
  if tier not in TIERS:
1164
1170
  raise ValueError(f"tier must be one of {TIERS}")
1165
1171
  jobs = []
@@ -1173,7 +1179,10 @@ class Engine:
1173
1179
  job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
1174
1180
  self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
1175
1181
  merge=not retest or bool(picked), redo=retest and bool(picked), fresh=retest,
1176
- settings=self.settings(m))
1182
+ settings=self.settings(m),
1183
+ stop_early=pack.stop_early if stop_early is None else bool(stop_early))
1184
+ if job.stop_early:
1185
+ job.first = self.most_failed_first(pack)
1177
1186
  sv = self.serving(m)
1178
1187
  status = self.result_status(label, name, tier)
1179
1188
  job.discards = self._discards(job)
@@ -1251,6 +1260,36 @@ class Engine:
1251
1260
  except (TypeError, ValueError):
1252
1261
  return 1
1253
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
+
1254
1293
  def memory_need_gb(self, m):
1255
1294
  """GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
1256
1295
  server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
@@ -1609,17 +1648,17 @@ class Engine:
1609
1648
  return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
1610
1649
 
1611
1650
  # ---- running
1612
- def run(self, jobs, parallel=None):
1651
+ def run(self, jobs, parallel=None, parallel_hosted=None):
1613
1652
  """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1614
- models are served at a time (default: parallel_models for this machine). add_jobs() can add
1615
- 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."""
1616
1656
  self._cancel.clear()
1617
1657
  self.emit("queue_started", jobs=jobs)
1618
1658
  models = sorted({j.label for j in jobs if j.status == "waiting"})
1619
1659
  machine = bool(jobs) and self.needs_machine(jobs)
1620
- lock = (self.machine_lock(f"{jobs[0].tier} run: {', '.join(models[:3])}"
1621
- + (f" +{len(models) - 3}" if len(models) > 3 else ""))
1622
- 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
1623
1662
  order = []
1624
1663
  for j in jobs:
1625
1664
  if j.label not in order:
@@ -1627,10 +1666,13 @@ class Engine:
1627
1666
  groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1628
1667
  with self._run_lock:
1629
1668
  self._run = {"jobs": jobs, "pending": [g for g in groups if g[1]], "open": True, "machine": machine,
1630
- "cond": threading.Condition(self._run_lock)}
1669
+ "cond": threading.Condition(self._run_lock), "stack": stack, "what": what}
1631
1670
  try:
1632
- with lock:
1633
- 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())))
1634
1676
  if self._cancel.is_set():
1635
1677
  raise Cancelled()
1636
1678
  except Cancelled:
@@ -1657,14 +1699,13 @@ class Engine:
1657
1699
  def add_jobs(self, new):
1658
1700
  """Add planned jobs to the run going on, after its other jobs. Returns (added, why not): a
1659
1701
  pack already waiting or running in it isn't added twice, and nothing is added once the run is
1660
- 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.
1661
1704
  Runs on the caller's thread, so the caller shows what was added (no event)."""
1662
1705
  with self._run_lock:
1663
1706
  r = self._run
1664
1707
  if not r or not r["open"] or self._cancel.is_set():
1665
1708
  return [], "the run is finishing"
1666
- if not r["machine"] and self.needs_machine(new):
1667
- return [], "the run going uses only hosted models, and these need this machine"
1668
1709
  busy = {j.key for j in r["jobs"] if j.status in ("waiting", "loading", "running")}
1669
1710
  added = [j for j in new if j.status == "waiting" and j.key not in busy]
1670
1711
  r["jobs"].extend(added)
@@ -1677,33 +1718,51 @@ class Engine:
1677
1718
  r["cond"].notify_all()
1678
1719
  return added, None if added else "everything picked is already in the run"
1679
1720
 
1680
- def _run_lanes(self, parallel):
1681
- """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1682
- model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1683
- next to the ones running (memory_need_gb; an unknown need is left to the setting). Lanes
1684
- 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."""
1685
1727
  pending, running, told = self._run["pending"], {}, set()
1686
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]
1687
1735
 
1688
1736
  def blocked(label):
1689
1737
  """Why the next model can't start now (None: it can)."""
1690
- 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:
1691
1742
  return None
1692
- if len(running) >= parallel:
1743
+ if len(local) >= parallel:
1693
1744
  return ""
1694
1745
  m = self.model(label)
1695
- if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
1696
- 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)))
1697
1748
  return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
1698
1749
  "on its own")
1699
1750
  need = self.memory_need_gb(m)
1700
- used = sum(n for n in running.values() if n)
1751
+ used = sum(running[l] or 0 for l in local)
1701
1752
  room = self.memory_available_gb()
1702
1753
  if need and used + need > room:
1703
- 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 "
1704
1755
  "starts when there's room")
1705
1756
  return None
1706
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
+
1707
1766
  def lane():
1708
1767
  while True:
1709
1768
  mine, note = None, None
@@ -1713,33 +1772,41 @@ class Engine:
1713
1772
  cond.notify_all()
1714
1773
  return
1715
1774
  # in order, except that a model added again while it runs waits for itself to end
1716
- # without holding up the ones behind it
1717
- nxt = next((i for i, g in enumerate(pending) if g[0] not in running), None)
1718
- label = pending[nxt][0] if nxt is not None else None
1719
- why = blocked(label) if label else "" # none can start: a model still runs, more may come
1720
- if why is None:
1721
- label, mine = pending.pop(nxt)
1722
- running[label] = self.memory_need_gb(self.model(label))
1723
- elif why and label not in told:
1724
- told.add(label)
1725
- note = f"{label} waits: {why}"
1726
- else:
1727
- 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)
1728
1795
  if note:
1729
1796
  self.emit("model_waiting", label=label, message=note)
1730
1797
  if mine is None:
1731
1798
  continue
1732
1799
  try:
1800
+ if not is_hosted(label) and self.cfg["servers"][mine[0].model["server"]].get("cmd"):
1801
+ take_machine()
1733
1802
  self._run_lane(label, mine)
1734
1803
  finally:
1735
1804
  with cond:
1736
1805
  running.pop(label, None)
1737
1806
  cond.notify_all()
1738
1807
 
1739
- if parallel == 1:
1740
- lane()
1741
- return
1742
- 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)]
1743
1810
  for t in threads:
1744
1811
  t.start()
1745
1812
  for t in threads:
@@ -2005,13 +2072,30 @@ class Engine:
2005
2072
 
2006
2073
  @staticmethod
2007
2074
  def round_order(job, rep):
2008
- """The tests in the order round `rep` asks them: the pack's order first, then a fixed
2009
- shuffle per round (the same on every run, so resuming and reruns ask in the same order)."""
2075
+ """The tests in the order round `rep` asks them: the pack's order first (with an early stop,
2076
+ the most failed first), then a fixed shuffle per round (the same on every run, so resuming
2077
+ and reruns ask in the same order)."""
2010
2078
  tests = list(job.tests)
2011
2079
  if rep:
2012
2080
  random.Random(f"{job.pack.name}:{rep}").shuffle(tests)
2081
+ elif job.first:
2082
+ rank = {tid: i for i, tid in enumerate(job.first)}
2083
+ tests.sort(key=lambda t: rank.get(t["id"], len(rank)))
2013
2084
  return tests
2014
2085
 
2086
+ def most_failed_first(self, pack):
2087
+ """The pack's test ids, the ones models failed most often first (critical failures count
2088
+ double), from every model's stored results; ties keep the pack's order."""
2089
+ fails, seen = collections.Counter(), collections.Counter()
2090
+ for m in self.cfg["models"]:
2091
+ for r in self.records(m["label"], pack.name):
2092
+ if client.server_error_row(r):
2093
+ continue
2094
+ seen[r["test"]] += 1
2095
+ fails[r["test"]] += (not r["pass"]) + (r.get("severity") == "critical")
2096
+ order = {t["id"]: i for i, t in enumerate(pack.tests)}
2097
+ return sorted(order, key=lambda tid: (-(fails[tid] / seen[tid] if seen[tid] else 0), order[tid]))
2098
+
2015
2099
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
2016
2100
  SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "vram_peak_gb", "kernel_share_max", "machine",
2017
2101
  "loaded_alongside")
@@ -2143,7 +2227,10 @@ class Engine:
2143
2227
  # a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
2144
2228
  tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
2145
2229
  warned_reuse = False
2230
+ stopped = self._stop_reason(job, mine)
2146
2231
  for rep in range(job.repeat):
2232
+ if stopped:
2233
+ break
2147
2234
  for test in self.round_order(job, rep):
2148
2235
  if (test["id"], rep) in done_keys:
2149
2236
  continue
@@ -2237,6 +2324,10 @@ class Engine:
2237
2324
  job.passed += bool(g["pass"])
2238
2325
  job.failed += not g["pass"]
2239
2326
  self.emit("request_done", job=job, record=rec)
2327
+ mine.append(rec)
2328
+ stopped = self._stop_reason(job, mine)
2329
+ if stopped:
2330
+ break
2240
2331
  job.status, job.finished = "done", time.time()
2241
2332
  merged = {(r["test"], r["repeat"]): r for r in kept}
2242
2333
  merged.update({(r["test"], r["repeat"]): r for r in new})
@@ -2246,9 +2337,17 @@ class Engine:
2246
2337
  os.remove(partial)
2247
2338
  with contextlib.suppress(OSError):
2248
2339
  os.remove(self._sittings_path(job))
2249
- job.note = f"{job.passed}/{job.done} passed"
2340
+ job.note = f"{job.passed}/{job.done} passed" + (f"; stopped early, FAIL is certain: {stopped}" if stopped else "")
2250
2341
  self.emit("job_done", job=job)
2251
2342
 
2343
+ def _stop_reason(self, job, rows):
2344
+ """Why the job stops early now (verdict.fail_certain), or None."""
2345
+ if not job.stop_early:
2346
+ return None
2347
+ from . import verdict
2348
+ return verdict.fail_certain(job.pack, [r for r in rows if not client.server_error_row(r)],
2349
+ self.cfg["defaults"]["repeat"])
2350
+
2252
2351
  def _partial_to_history(self, job, keep=False):
2253
2352
  """Keep an unfinished run that is set aside in history/; keep: copy it (some of its answers stay)."""
2254
2353
  partial = job.out_path + ".partial.jsonl"
@@ -49,6 +49,7 @@ class Pack:
49
49
  gate: dict # release criteria (see verdict.py)
50
50
  screen: int # tests in a screening run
51
51
  certify_repeat: int # repeats in a certification run (0 = models.toml default)
52
+ stop_early: bool = False # stop the pack once its FAIL is certain (pack.toml stop_early)
52
53
 
53
54
  @property
54
55
  def modules(self):
@@ -221,7 +222,8 @@ def load_pack(path):
221
222
  system=system, needs=list(needs), tools=tools, tests=tests,
222
223
  fingerprint=_fingerprint(system, tools, meta.get("grader", "answer"), tests, images),
223
224
  gate=gate, screen=int(meta.get("screen", 10)),
224
- certify_repeat=int(meta.get("certify", {}).get("repeat", 0)))
225
+ certify_repeat=int(meta.get("certify", {}).get("repeat", 0)),
226
+ stop_early=bool(meta.get("stop_early", False)))
225
227
 
226
228
 
227
229
  def load_packs(packs_dir=None, errors=None):
@@ -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
@@ -745,6 +746,9 @@ def main(argv=None):
745
746
  "pack in --packs, else pack:id (and --packs defaults to those packs)")
746
747
  p.add_argument("--retest", "--force", action="store_true", dest="force",
747
748
  help="ask again even if already answered: the packs, or just the --tests (earlier answers go to history/)")
749
+ p.add_argument("--stop-early", action="store_true",
750
+ help="stop each pack once its FAIL is certain (a critical failure, or a bar out of reach), "
751
+ "asking first what models fail most (default: each pack's stop_early)")
748
752
  p.add_argument("--repeat", type=int, help="times each question is asked, any tier (default: 1 for smoke and "
749
753
  "screen, the pack's certification count for certify)")
750
754
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
@@ -752,6 +756,9 @@ def main(argv=None):
752
756
  p.add_argument("--smoke", action="store_const", const="smoke", dest="tier", help="same as --tier smoke")
753
757
  p.add_argument("--parallel", type=int, metavar="N",
754
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)")
755
762
  p.add_argument("--dry-run", action="store_true", help="print the plan and commands without running")
756
763
  p.add_argument("--brief", action="store_true", help="end with one line per model instead of the scorecards")
757
764
  p.add_argument("--models", default=None, help="default: the workspace's models.toml")
@@ -766,6 +773,9 @@ def main(argv=None):
766
773
  if args.parallel is not None and args.parallel < 1:
767
774
  sys.exit("--parallel must be 1 or more")
768
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()
769
779
  models = e.cfg["models"]
770
780
  labels = [m["label"] for m in models if not m.get("remote")] # hosted APIs cost money: only by name
771
781
  if args.only:
@@ -800,7 +810,7 @@ def main(argv=None):
800
810
  if args.tests:
801
811
  tests, pack_names = parse_tests(args.tests, pack_names if (args.packs or args.preset) else None, e.packs)
802
812
  try:
803
- jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests)
813
+ jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests, args.stop_early or None)
804
814
  except packs_mod.PackError as err:
805
815
  sys.exit(str(err))
806
816
 
@@ -834,12 +844,12 @@ def main(argv=None):
834
844
 
835
845
  signal.signal(signal.SIGINT, stop)
836
846
  signal.signal(signal.SIGTERM, stop)
837
- 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)
838
848
  if side_by_side > 1:
839
849
  printer.side_by_side = True
840
850
  say(f"up to {side_by_side} models at a time: one line per answer; each answer notes the models "
841
851
  "it ran alongside, since they share this machine's speed")
842
- worker = threading.Thread(target=e.run, args=(jobs, parallel))
852
+ worker = threading.Thread(target=e.run, args=(jobs, parallel, parallel_hosted))
843
853
  worker.start()
844
854
  while worker.is_alive():
845
855
  worker.join(0.5)
@@ -390,6 +390,10 @@ picked with e, if any). Without it, answered questions are skipped. Before anyth
390
390
  the packs with earlier answers and lets you choose for each: Zero (ask everything again), Continue
391
391
  (keep what's answered, ask the rest) or Leave out. Replaced answers go to history/, never deleted.
392
392
 
393
+ [b]Stop a pack once it fails[/b]: stop each pack as soon as its FAIL is certain (a critical failure, or
394
+ an accuracy or truncation bar out of reach) and move on, asking first the questions models fail most.
395
+ Packs with stop_early = true in pack.toml always do this. A later Certify fills in the rest.
396
+
393
397
  [b]Keys[/b]
394
398
  s start (with a run going: add to it, or queue it) r results
395
399
  a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
@@ -652,6 +656,8 @@ class SetupScreen(Screen):
652
656
  yield Label("Models at a time")
653
657
  yield Input("", id="parallel", type="integer", max_length=1, placeholder="1")
654
658
  yield Checkbox("Retest (ask again, even if already answered)", id="force")
659
+ with Horizontal(id="options2"):
660
+ yield Checkbox("Stop a pack once it fails (a critical failure, or a bar out of reach)", id="stop-early")
655
661
  yield Static(id="estimate")
656
662
  with Horizontal(id="buttons"):
657
663
  yield Button("Start [s]", id="start", variant="success")
@@ -969,7 +975,7 @@ class SetupScreen(Screen):
969
975
  resumed = [j for j in run if "done earlier" in j.note]
970
976
  if resumed:
971
977
  text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
972
- 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())
973
979
  if at_once > 1:
974
980
  text += (f"\n[cyan]Up to {at_once} models at a time[/cyan][dim] (the time above assumes one at a "
975
981
  "time). Answers note the models they ran alongside, since those share the machine's "
@@ -1009,6 +1015,8 @@ class SetupScreen(Screen):
1009
1015
  self.query_one(f"#tier-{tier}", RadioButton).value = True
1010
1016
  if "force" in sel:
1011
1017
  self.query_one("#force", Checkbox).value = bool(sel["force"])
1018
+ if "stop_early" in sel:
1019
+ self.query_one("#stop-early", Checkbox).value = bool(sel["stop_early"])
1012
1020
  self.query_one("#filter", Input).value = ""
1013
1021
  self.refresh_models()
1014
1022
  if not quiet:
@@ -1043,7 +1051,7 @@ class SetupScreen(Screen):
1043
1051
  return
1044
1052
  picks = self.current_picks(suites)
1045
1053
  sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1046
- "parallel": self.parallel()}
1054
+ "parallel": self.parallel(), "stop_early": self.query_one("#stop-early", Checkbox).value}
1047
1055
  e = self.app.engine
1048
1056
  jobs = e.plan(labels, suites, repeat, tier, True, picks)
1049
1057
  if not any(j.status == "waiting" for j in jobs):
@@ -1573,7 +1581,7 @@ class QueuedRun:
1573
1581
  """(jobs, None), or (None, why it can't run) now."""
1574
1582
  sel = self.sel
1575
1583
  jobs = [j for j in eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], self.retest,
1576
- self.tests(eng)) if j.key not in self.leave]
1584
+ self.tests(eng), sel.get("stop_early") or None) if j.key not in self.leave]
1577
1585
  waiting = [j for j in jobs if j.status == "waiting"]
1578
1586
  if not waiting:
1579
1587
  return None, "every pack left is already done"
@@ -2122,8 +2130,7 @@ class RunScreen(Screen):
2122
2130
  @property
2123
2131
  def side_by_side(self):
2124
2132
  """Several models run at a time in this run."""
2125
- return min(self.parallel or self.app.engine.parallel_models(),
2126
- len({j.label for j in self.jobs if j.status != "skipped"})) > 1
2133
+ return self.app.engine.models_at_once({j.label for j in self.jobs if j.status != "skipped"}, self.parallel) > 1
2127
2134
 
2128
2135
  def active(self):
2129
2136
  """Models being loaded or run now, in queue order."""
@@ -2438,7 +2445,7 @@ class RunScreen(Screen):
2438
2445
  rough |= j.key in self.rough
2439
2446
  left += (j.total - j.done) * spr
2440
2447
  models.add(j.label)
2441
- lanes = max(1, min(self.parallel or self.app.engine.parallel_models(), len(models)))
2448
+ lanes = max(1, self.app.engine.models_at_once(models, self.parallel))
2442
2449
  return left / lanes, rough
2443
2450
 
2444
2451
  def update_overall(self):
@@ -3076,7 +3083,7 @@ class EvalsApp(App):
3076
3083
  #tier-row Label { padding: 1 1 0 0; }
3077
3084
  #tier-row RadioSet { layout: horizontal; width: auto; height: auto; }
3078
3085
  #tier-row RadioButton { width: auto; margin-right: 2; }
3079
- #options { height: 3; padding: 0 1; align-vertical: middle; }
3086
+ #options, #options2 { height: 3; padding: 0 1; align-vertical: middle; }
3080
3087
  #options Label { padding: 1 1 0 0; }
3081
3088
  #options Input { width: 12; }
3082
3089
  #options #parallel { width: 8; }
@@ -73,6 +73,26 @@ def consistency(records):
73
73
  return ok / len(full), len(full)
74
74
 
75
75
 
76
+ def fail_certain(pack, records, repeat_default=3):
77
+ """Why this pack's verdict is FAIL whatever the remaining answers of its certification say,
78
+ or None: a critical failure, or a bar out of reach even if every answer still to come passes
79
+ (accuracy) or none of them is cut off (truncation). What an early stop checks after each answer."""
80
+ crit = next((r for r in records if r.get("severity") == "critical"), None)
81
+ if crit:
82
+ return f"critical failure ({crit['test']})"
83
+ n = len(records)
84
+ total = max(n, len(pack.tests) * (pack.certify_repeat or repeat_default))
85
+ passed = sum(bool(r["pass"]) for r in records)
86
+ bar = pack.gate.get("min_accuracy", 0.8)
87
+ if n and wilson(passed + total - n, total)[1] < bar:
88
+ return (f"{n - passed} wrong of {n}: under the {100 * bar:.0f}% bar even if all "
89
+ f"{total - n} answers left pass")
90
+ cut = sum(r.get("finish") == "length" for r in records)
91
+ if "max_truncation" in pack.gate and cut / total > pack.gate["max_truncation"]:
92
+ return f"{cut} answers cut off by max_tokens: over the {100 * pack.gate['max_truncation']:g}% limit for the pack"
93
+ return None
94
+
95
+
76
96
  def evaluate(pack, records, repeat_default=3, speed_gate=False):
77
97
  """Quality verdict for one model on one pack. `records` are result rows from
78
98
  results/<label>/<pack>.json. The p90 time limit is judged per machine by latency() unless
@@ -112,6 +132,9 @@ def evaluate(pack, records, repeat_default=3, speed_gate=False):
112
132
  if hi < min_acc:
113
133
  fails.append(f"accuracy {100 * passed / n:.1f}% (95% CI {100 * lo:.0f}-{100 * hi:.0f}%) is below "
114
134
  f"the {100 * min_acc:.0f}% bar")
135
+ certain = fail_certain(pack, records, repeat_default)
136
+ if certain and not crit and not fails: # a run stopped early (or a partial one) that can't pass any more
137
+ fails.append(certain)
115
138
  if "min_consistency" in gate and cons is not None and certified and cons < gate["min_consistency"]:
116
139
  fails.append(f"only {100 * cons:.0f}% of {groups} rephrased cases answered consistently "
117
140
  f"(needs {100 * gate['min_consistency']:.0f}%)")
@@ -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__))
@@ -151,6 +152,52 @@ class Starters(unittest.TestCase):
151
152
  self.assertIn("separate the models", tuieval(self.ws, "compare", "--speed").stdout)
152
153
 
153
154
 
155
+ class EarlyStop(unittest.TestCase):
156
+ """--stop-early ends a pack once its FAIL is certain, and asks what models fail most first."""
157
+
158
+ def test_wrong_model_stops_and_good_model_finishes(self):
159
+ with tempfile.TemporaryDirectory() as tmp:
160
+ ws = os.path.join(tmp, "ws")
161
+ tuieval(tmp, "init", ws)
162
+ tuieval(ws, "new-pack", "demo", "--grader", "answer")
163
+ packs = os.path.join(ws, "packs")
164
+ good, bad = Mock(packs, "oracle"), Mock(packs, "wrong")
165
+ try:
166
+ with open(os.path.join(ws, "models.toml"), "a") as f:
167
+ f.write(f'\n[servers.good]\nurl = "http://127.0.0.1:{good.port}"\n'
168
+ f'\n[servers.bad]\nurl = "http://127.0.0.1:{bad.port}"\n')
169
+ tuieval(ws, "add", "mock", "--server", "good", "--label", "good-mock")
170
+ tuieval(ws, "add", "mock", "--server", "bad", "--label", "bad-mock")
171
+ out = tuieval(ws, "run", "--tier", "certify", "--stop-early").stdout
172
+ finally:
173
+ good.stop()
174
+ bad.stop()
175
+ self.assertIn("bad-mock/demo: done", out)
176
+ self.assertIn("stopped early, FAIL is certain", out)
177
+ bad_res = json.loads(read(os.path.join(ws, "results", "bad-mock", "demo.json")))["results"]
178
+ good_res = json.loads(read(os.path.join(ws, "results", "good-mock", "demo.json")))["results"]
179
+ self.assertEqual(len(good_res), 18)
180
+ self.assertLess(len(bad_res), 18)
181
+ self.assertIn("bad-mock: not ready for", tuieval(ws, "verdict").stdout.split("Details")[0])
182
+ # bad-mock's failures now come first for the next model with an early stop
183
+ code = ("from tuieval import engine; e = engine.Engine(); p = e.packs['demo']; "
184
+ "first = e.most_failed_first(p); fails = {r['test'] for r in e.records('bad-mock', 'demo') "
185
+ "if not r['pass']}; print(set(first[:len(fails)]) == fails)")
186
+ self.assertEqual(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
187
+ env=dict(os.environ, TUIEVAL_HOME=ws)).stdout.strip(), "True")
188
+
189
+ def test_fail_certain(self):
190
+ from tuieval import verdict
191
+ pack = types.SimpleNamespace(tests=[{"id": str(i)} for i in range(10)], certify_repeat=3,
192
+ gate={"min_accuracy": 0.8, "max_truncation": 0.05})
193
+ row = lambda ok, **kw: {"test": "0", "pass": ok, **kw}
194
+ self.assertIsNone(verdict.fail_certain(pack, [row(False)] * 2)) # 28 of 30 could still pass
195
+ self.assertIsNone(verdict.fail_certain(pack, [row(False)] * 9)) # best case 21/30: CI reaches 83%
196
+ self.assertIn("under the 80% bar", verdict.fail_certain(pack, [row(False)] * 12))
197
+ self.assertIn("critical failure", verdict.fail_certain(pack, [row(False, severity="critical")]))
198
+ self.assertIn("cut off", verdict.fail_certain(pack, [row(True, finish="length")] * 2)) # 2 of 30 > 5%
199
+
200
+
154
201
  class WorkspaceGraders(unittest.TestCase):
155
202
  """A workspace's graders/ and a pack's grader.py are loaded, with their critical flag."""
156
203
 
@@ -237,6 +284,11 @@ class Units(unittest.TestCase):
237
284
  job = lambda key, label, total, done=0, status="waiting": types.SimpleNamespace(
238
285
  key=key, label=label, total=total, done=done, status=status)
239
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)
240
292
  run.parallel, run.rough = 1, {"local/new"}
241
293
  run.jobs = [job("hosted/a", "hosted", 100, 100, "done"), job("local/a", "local", 200, 4, "running"),
242
294
  job("local/new", "local", 50)]
@@ -808,6 +860,66 @@ class MarkupInText(unittest.TestCase):
808
860
  self.assertEqual(json.loads(p.stdout.strip().splitlines()[-1]), [["m/br", "done", 2]], p.stderr[-2000:])
809
861
 
810
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
+
811
923
  class AddToRun(unittest.TestCase):
812
924
  """More evals while a run is going: added to it (they run after its other jobs) or queued after it."""
813
925
 
File without changes
File without changes
File without changes