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.
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/CHANGELOG.md +2 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/PKG-INFO +1 -1
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/models.md +3 -1
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/writing-packs.md +3 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/engine.py +144 -45
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/packs.py +3 -1
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/run_evals.py +13 -3
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/tui.py +14 -7
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/verdict.py +23 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/tests/test_tuieval.py +112 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/.gitignore +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/LICENSE +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/README.md +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/builtin.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/cli.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/client.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/compare.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/logs.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/pta.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/yamlout.py +0 -0
- {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.
|
|
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 `
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
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)
|
|
1615
|
-
|
|
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
|
-
|
|
1621
|
-
|
|
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
|
|
1633
|
-
|
|
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
|
|
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
|
|
1682
|
-
|
|
1683
|
-
next to the ones running (memory_need_gb; an unknown need is left to the setting)
|
|
1684
|
-
|
|
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
|
|
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(
|
|
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
|
|
1696
|
-
alone = label if self.runs_alone(m) else next(l for l in
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
|
1740
|
-
|
|
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
|
|
2009
|
-
shuffle per round (the same on every run, so resuming
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev77 → tuieval-0.2.0.dev81}/src/tuieval/templates/packs/tool_call/tools.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|