tuieval 0.2.0.dev43__tar.gz → 0.2.0.dev55__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.dev43 → tuieval-0.2.0.dev55}/CHANGELOG.md +2 -1
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/PKG-INFO +1 -1
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/models.md +5 -6
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/cli.py +1 -1
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/machines.py +4 -4
- tuieval-0.2.0.dev55/src/tuieval/pta.py +176 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/run_evals.py +9 -4
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/tui.py +32 -33
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/watch_proxy.py +7 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/tests/mock_server.py +9 -1
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/tests/test_tuieval.py +51 -31
- tuieval-0.2.0.dev43/src/tuieval/pta.py +0 -185
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/.gitignore +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/LICENSE +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/README.md +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/builtin.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/client.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/compare.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/engine.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/yamlout.py +0 -0
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Faster start on Macs: the GPU core count comes from `ioreg` instead of `system_profiler`, which took seconds on some Macs.
|
|
5
6
|
- A model a server won't serve (HTTP 401-404, e.g. OpenRouter's "No endpoints found") is never judged on it: those answers don't count, and the model stops at once instead of failing every question.
|
|
6
|
-
- PTA index:
|
|
7
|
+
- PTA index: parsimony (tokens used) and speed (time), each 100 for the best and 15 points less per doubling, and accuracy per model, as bars: the first Results tab (where `r` lands), `tuieval pta` and the report. Server errors aren't answers, and models with few answers are left out unless picked.
|
|
7
8
|
- Run several models at a time: `parallel_models` per machine, **Models at a time** in the TUI, or `tuieval run --parallel N` (default 1). Models wait for memory to fit, and answers note what ran alongside them.
|
|
8
9
|
- More evals during a run: `n` opens Setup, and Start adds them to the run going (same tier) or queues them after it.
|
|
9
10
|
- A new workspace's `models.toml` is 23 lines: the llama, local and OpenRouter servers and the defaults are built in (docs/models.md lists them in full; a server defined in `models.toml` replaces the built-in one with that name). Existing workspaces keep their settings; built-in servers they don't define become available, and adding a model still prefers the workspace's own servers.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev55
|
|
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
|
|
@@ -86,13 +86,13 @@ By default a run serves one model at a time. On a machine with room for more, se
|
|
|
86
86
|
|
|
87
87
|
## PTA index
|
|
88
88
|
|
|
89
|
-
The **PTA index** puts
|
|
89
|
+
The **PTA index** puts parsimony (tokens), speed (time) and accuracy side by side, 0-100 each and higher is better, as a bar per score for each model, with the numbers and a table underneath. It's the first tab in Results, where `r` opens, and it's also `tuieval pta` and a section in `tuieval report`. There's no combined number; verdicts and critical failures stay in Production readiness.
|
|
90
90
|
|
|
91
|
-
- **P
|
|
92
|
-
- **T
|
|
93
|
-
- **A accuracy
|
|
91
|
+
- **P parsimony** (a score out of 100, not a percentage): from the tokens used, reasoning and answer, on every question compared (each question at its median over repeats). The fewest tokens score 100 (★ in the **tokens** column), and every doubling costs 15 points: `100 − 15 × log2(this model's tokens ÷ fewest)`, never below 0. Unlike speed it doesn't depend on your machine: it's how much a model says to get to an answer, which is what you pay for on an API and what fills its context.
|
|
92
|
+
- **T speed** (a score out of 100, not a percentage): the same from the total time to answer those questions. The fastest model scores 100 (★ on its total time), and every doubling of the total time costs 15 points: `100 − 15 × log2(this model's total ÷ fastest total)`, never below 0 (about 100× slower). Twice as slow is 85, 10× slower is 50. Adding a faster model lowers the others. The table's **total time** column has the times themselves (lower is better).
|
|
93
|
+
- **A accuracy** (a percentage): the share of answers to those questions that passed.
|
|
94
94
|
|
|
95
|
-
Models are compared on the questions all of them answered, so one that skipped some never looks faster. Pick the models and pack to compare in the TUI, or use `--only` and `--packs
|
|
95
|
+
Models are compared on the questions all of them answered, so one that skipped some never looks faster. Server and connection errors aren't answers: a model with only those is listed as having none. A model with fewer than half as many answers as the most-answered one is left out (and listed), or it would shrink the shared questions for everyone. Pick the models and pack to compare in the TUI, or use `--only` and `--packs`: models you pick are always compared.
|
|
96
96
|
|
|
97
97
|
## Tuning
|
|
98
98
|
|
|
@@ -140,7 +140,6 @@ On Apple Silicon Macs, once the system's GPU allocations pass about half of RAM,
|
|
|
140
140
|
|---|---|
|
|
141
141
|
| `cmd` | command that starts the server (no `cmd` = an already-running server at `url`). Placeholders: `{model}`, `{served_name}`, `{port}`, `{mmproj}`, `{ctx}`, `{kv_type}`, `{root}` (the workspace), and the tune placeholders. An argument `env:NAME=value` sets an environment variable instead. |
|
|
142
142
|
| `url` | an already-running server's base URL |
|
|
143
|
-
| `private` | `true` for a server on a machine you control (e.g. your own box on the network): its models score 100 for privacy in the PTA index |
|
|
144
143
|
| `port` | the port `cmd` serves on |
|
|
145
144
|
| `cwd` | folder to start `cmd` in |
|
|
146
145
|
| `model_is_path` | `model` is a file or folder: check it exists before starting |
|
|
@@ -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.dev55'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev55')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -14,7 +14,7 @@ Start here
|
|
|
14
14
|
|
|
15
15
|
Day to day
|
|
16
16
|
tuieval compare [...] scorecards (--speed, --pairwise, --failures)
|
|
17
|
-
tuieval pta the PTA index:
|
|
17
|
+
tuieval pta the PTA index: parsimony, speed and accuracy per model, as bars
|
|
18
18
|
tuieval report the verdicts with their evidence, as a markdown file
|
|
19
19
|
tuieval history every verdict change, newest first
|
|
20
20
|
tuieval items tests that don't separate models or look broken
|
|
@@ -65,10 +65,10 @@ def detect():
|
|
|
65
65
|
# macOS default when unset: about 2/3 of RAM up to 36 GB, 3/4 above.
|
|
66
66
|
limit = wired / 1024 if wired > 0 else ram * (0.75 if ram > 36 else 2 / 3)
|
|
67
67
|
gpu_cores = None
|
|
68
|
-
try:
|
|
69
|
-
out = subprocess.run(["
|
|
70
|
-
timeout=
|
|
71
|
-
m = re.search(r"
|
|
68
|
+
try: # the GPU driver's own count: instant, where system_profiler can take seconds (CI's virtual Macs)
|
|
69
|
+
out = subprocess.run(["ioreg", "-rc", "AGXAccelerator", "-d1"], capture_output=True, text=True,
|
|
70
|
+
timeout=5).stdout
|
|
71
|
+
m = re.search(r'"gpu-core-count"\s*=\s*(\d+)', out)
|
|
72
72
|
gpu_cores = int(m.group(1)) if m else None
|
|
73
73
|
except (OSError, subprocess.TimeoutExpired):
|
|
74
74
|
pass
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""PTA index: parsimony, speed (time) and accuracy per model, 0-100 each and higher is better, shown
|
|
2
|
+
as a bar per score for each model.
|
|
3
|
+
|
|
4
|
+
P parsimony a score out of 100, from the tokens (reasoning and answer) used on every question
|
|
5
|
+
compared (each question at its median over repeats): 100 for the model that used the
|
|
6
|
+
fewest, POINTS_PER_DOUBLING points less for every doubling (0 at about 100 times more).
|
|
7
|
+
Unlike T it doesn't depend on the machine: it's how much a model says to get there.
|
|
8
|
+
T speed a score out of 100, the same from the total time to answer those questions: 100 for
|
|
9
|
+
the fastest model. Log scales, so models that are all far behind the best still differ.
|
|
10
|
+
A accuracy a percentage: the share of answers to those questions that passed.
|
|
11
|
+
|
|
12
|
+
Models are compared on the questions all of them answered, so one that skipped some never looks
|
|
13
|
+
faster. Server and connection errors aren't answers. By default a model with fewer than half as many
|
|
14
|
+
answers as the most-answered model is left out (listed, so it can be picked), or it would shrink the
|
|
15
|
+
shared questions for everyone. There's no combined number: the three stay side by side, and verdicts
|
|
16
|
+
and critical failures stay where they are (verdict.py).
|
|
17
|
+
"""
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
from rich.markup import escape as rich_escape
|
|
21
|
+
|
|
22
|
+
from . import compare
|
|
23
|
+
|
|
24
|
+
MIN_SHARE = 0.5 # fewer answers than this share of the most-answered model's: left out by default
|
|
25
|
+
POINTS_PER_DOUBLING = 15
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def score(total, best):
|
|
29
|
+
"""P and T: 100 for the best (lowest) total, POINTS_PER_DOUBLING less for every doubling, never below 0."""
|
|
30
|
+
if not total or not best:
|
|
31
|
+
return None
|
|
32
|
+
return max(0.0, 100 - POINTS_PER_DOUBLING * math.log2(total / best))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def index(rows, models=None):
|
|
36
|
+
"""{"questions": n shared, "models": [{model, P, T, A, tokens, total_s, passed, answers, alongside,
|
|
37
|
+
fewest, fastest}],
|
|
38
|
+
"left_out": [(model, questions answered)], "no_answers": [model], "most": questions answered by the
|
|
39
|
+
most-answered model}, best accuracy first. models: the ones to compare (default: every model in rows
|
|
40
|
+
with enough answers; models named here are compared whatever their count)."""
|
|
41
|
+
no_answers = sorted({r["model"] for r in rows} - {r["model"] for r in rows if not r.get("error")})
|
|
42
|
+
rows = [r for r in rows if not r.get("error")] # a server or connection error isn't an answer
|
|
43
|
+
tests = compare.per_question(rows)
|
|
44
|
+
counts = {}
|
|
45
|
+
for t in tests:
|
|
46
|
+
for m in t["cells"]:
|
|
47
|
+
counts[m] = counts.get(m, 0) + 1
|
|
48
|
+
most = max(counts.values(), default=0)
|
|
49
|
+
if models:
|
|
50
|
+
no_answers = [m for m in no_answers if m in models]
|
|
51
|
+
models, left_out = sorted(m for m in models if m in counts), []
|
|
52
|
+
else:
|
|
53
|
+
models = sorted(m for m in counts if counts[m] >= MIN_SHARE * most)
|
|
54
|
+
left_out = sorted(((m, n) for m, n in counts.items() if m not in models), key=lambda x: (-x[1], x[0]))
|
|
55
|
+
shared = [t for t in tests if models and all(m in t["cells"] for m in models)]
|
|
56
|
+
out = []
|
|
57
|
+
for m in models:
|
|
58
|
+
cells = [t["cells"][m] for t in shared]
|
|
59
|
+
answers = sum(c["n"] for c in cells)
|
|
60
|
+
out.append({"model": m, "tokens": sum(c["tokens"] or 0 for c in cells),
|
|
61
|
+
"total_s": sum(c["secs"] or 0 for c in cells),
|
|
62
|
+
"passed": sum(c["passed"] for c in cells), "answers": answers,
|
|
63
|
+
"A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
|
|
64
|
+
# answers that ran while other models were served shared the machine's speed
|
|
65
|
+
"alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
|
|
66
|
+
fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
|
|
67
|
+
fewest = min((x["tokens"] for x in out if x["tokens"] > 0), default=None)
|
|
68
|
+
for x in out:
|
|
69
|
+
x["P"], x["T"] = score(x["tokens"], fewest), score(x["total_s"], fastest)
|
|
70
|
+
x["fewest"] = bool(fewest) and x["tokens"] == fewest
|
|
71
|
+
x["fastest"] = bool(fastest) and x["total_s"] == fastest
|
|
72
|
+
out.sort(key=lambda x: (-(x["A"] or 0), -(x["T"] or 0), x["model"]))
|
|
73
|
+
return {"questions": len(shared), "models": out, "left_out": left_out, "no_answers": no_answers,
|
|
74
|
+
"most": most}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
LETTERS = (("P", "parsimony", "/100", "cyan"), ("T", "speed", "/100", "magenta"), ("A", "accuracy", "%", "green"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def bars(result, width=20, markup=True):
|
|
81
|
+
"""Lines with a row per model and a bar per score (P, T, A), longer = better, each with its number:
|
|
82
|
+
the at-a-glance view. markup=False gives plain text (the report)."""
|
|
83
|
+
if not result["models"]:
|
|
84
|
+
return []
|
|
85
|
+
name_w = max(len(x["model"]) for x in result["models"])
|
|
86
|
+
cell_w = width + 6 # the bar, a space and "100%" or " 100"
|
|
87
|
+
head = " " * (name_w + 2) + "".join(f"{f'{k} {name}':<{cell_w}}" for k, name, _, _ in LETTERS)
|
|
88
|
+
lines = [f"[b]{head.rstrip()}[/b]" if markup else head.rstrip()]
|
|
89
|
+
for x in result["models"]:
|
|
90
|
+
cells = []
|
|
91
|
+
for k, _, unit, color in LETTERS:
|
|
92
|
+
v = x[k]
|
|
93
|
+
full = 0 if v is None else round(width * max(0, min(100, v)) / 100)
|
|
94
|
+
bar, rest = "█" * full, "░" * (width - full)
|
|
95
|
+
num = "-" if v is None else f"{v:.0f}{'%' if unit == '%' else ''}"
|
|
96
|
+
bar = f"[{color}]{bar}[/][grey37]{rest}[/]" if markup else bar + rest
|
|
97
|
+
cells.append(f"{bar} {num:>4}")
|
|
98
|
+
name = f"{x['model']:<{name_w}}"
|
|
99
|
+
lines.append(f"{rich_escape(name) if markup else name} " + " ".join(cells))
|
|
100
|
+
return lines
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def left_out_lines(result):
|
|
104
|
+
"""Plain sentences on the models not compared (too few answers, or only server errors) and on
|
|
105
|
+
answers that shared the machine with other models."""
|
|
106
|
+
out = []
|
|
107
|
+
if result["left_out"]:
|
|
108
|
+
out.append("Left out (fewer than half as many answers as the most-answered model; pick them under "
|
|
109
|
+
"Models or --only to include them): "
|
|
110
|
+
+ ", ".join(f"{m} ({n} of {result['most']})" for m, n in result["left_out"]))
|
|
111
|
+
if result["no_answers"]:
|
|
112
|
+
out.append("No answers, only server errors: " + ", ".join(result["no_answers"]))
|
|
113
|
+
shared = [x["model"] for x in result["models"] if x["alongside"]]
|
|
114
|
+
if shared:
|
|
115
|
+
out.append("Some answers ran alongside other models, which shared the machine's speed: " + ", ".join(shared))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _score(v, unit=""):
|
|
120
|
+
"""A is a percentage (%), P and T scores out of 100 (/100: on a log scale, not percentages)."""
|
|
121
|
+
return "-" if v is None else f"{v:.0f}{unit}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def table(result):
|
|
125
|
+
"""(header, rows) for a plain table of the index."""
|
|
126
|
+
header = ["model", "P parsimony /100", "T speed /100", "A accuracy %", "tokens", "total time",
|
|
127
|
+
"answers right"]
|
|
128
|
+
rows = [[x["model"], _score(x["P"]), _score(x["T"]), _score(x["A"], "%"),
|
|
129
|
+
f"{round(x['tokens']):,}" + (" ★" if x.get("fewest") else ""),
|
|
130
|
+
fmt_total(x["total_s"]) + (" ★" if x.get("fastest") else ""),
|
|
131
|
+
f"{x['passed']}/{x['answers']}"] for x in result["models"]]
|
|
132
|
+
return header, rows
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fmt_total(secs):
|
|
136
|
+
if not secs:
|
|
137
|
+
return "-"
|
|
138
|
+
if secs < 60:
|
|
139
|
+
return f"{secs:.1f}s"
|
|
140
|
+
secs = round(secs)
|
|
141
|
+
h, rem = divmod(secs, 3600)
|
|
142
|
+
return f"{h}h{rem // 60:02d}m" if h else f"{rem // 60}m{rem % 60:02d}s" if rem >= 60 else f"{rem}s"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def scope_note(result, models):
|
|
146
|
+
"""What the scores compare, in a sentence."""
|
|
147
|
+
if not result["questions"]:
|
|
148
|
+
return "These models have no question in common yet, so there's nothing to compare."
|
|
149
|
+
return (f"Compared on the {result['questions']} questions all {len(result['models'])} model(s) answered. "
|
|
150
|
+
f"A is a percentage. P parsimony and T speed are scores out of 100, not percentages: 100 = the fewest "
|
|
151
|
+
f"tokens / the fastest total time (★), {POINTS_PER_DOUBLING} points less for each doubling; a better "
|
|
152
|
+
"model added lowers the others.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def for_engine(e, labels=None, packs=None, results_dir=None):
|
|
156
|
+
"""(result, models) from a workspace's finished results (results on their way out excluded)."""
|
|
157
|
+
paths = [p for p in compare.default_paths(results_dir or e.results_dir) if not e.superseded(p)]
|
|
158
|
+
rows, _ = compare.load(paths) if paths else ([], [])
|
|
159
|
+
if packs:
|
|
160
|
+
rows = [r for r in rows if r["suite"] in packs]
|
|
161
|
+
models = sorted({r["model"] for r in rows} & set(labels) if labels else {r["model"] for r in rows})
|
|
162
|
+
return index(rows, models if labels else None), models
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def markdown(result, models):
|
|
166
|
+
"""The index as a markdown section (tuieval report)."""
|
|
167
|
+
lines = ["## PTA index", "", scope_note(result, models), ""]
|
|
168
|
+
if not result["questions"]:
|
|
169
|
+
return lines
|
|
170
|
+
lines += ["```"] + bars(result, markup=False) + ["```", ""]
|
|
171
|
+
header, rows = table(result)
|
|
172
|
+
lines += ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
|
|
173
|
+
lines += ["| " + " | ".join(r) + " |" for r in rows]
|
|
174
|
+
for line in left_out_lines(result):
|
|
175
|
+
lines += ["", line]
|
|
176
|
+
return lines
|
|
@@ -389,11 +389,11 @@ def report_markdown(e, labels=None):
|
|
|
389
389
|
|
|
390
390
|
def cmd_pta(argv):
|
|
391
391
|
p = argparse.ArgumentParser(prog="tuieval pta",
|
|
392
|
-
description="The PTA index:
|
|
393
|
-
"
|
|
392
|
+
description="The PTA index: parsimony (tokens), speed and accuracy per model (0-100, higher is better), as a "
|
|
393
|
+
"bar per score for each model and a table, over the questions all the models answered.")
|
|
394
394
|
p.add_argument("--only", help="comma-separated model labels (default: every model with results)")
|
|
395
395
|
p.add_argument("--packs", help="comma-separated packs (default: all)")
|
|
396
|
-
p.add_argument("--width", type=int, default=
|
|
396
|
+
p.add_argument("--width", type=int, default=20, help="bar width in characters (default 20)")
|
|
397
397
|
p.add_argument("--models")
|
|
398
398
|
p.add_argument("--results-dir")
|
|
399
399
|
a = p.parse_args(argv)
|
|
@@ -408,15 +408,20 @@ def cmd_pta(argv):
|
|
|
408
408
|
sys.exit("no finished results to compare" + (" for those models or packs" if labels or packs else ""))
|
|
409
409
|
out.print(pta.scope_note(result, models))
|
|
410
410
|
if not result["questions"]:
|
|
411
|
+
for line in pta.left_out_lines(result):
|
|
412
|
+
out.print(line, markup=False)
|
|
411
413
|
return
|
|
412
414
|
out.print()
|
|
413
|
-
for line in pta.
|
|
415
|
+
for line in pta.bars(result, a.width):
|
|
414
416
|
out.print(line)
|
|
415
417
|
header, rows = pta.table(result)
|
|
416
418
|
widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(header)]
|
|
417
419
|
out.print()
|
|
418
420
|
for line in [header] + rows:
|
|
419
421
|
out.print(" ".join(c.ljust(w) for c, w in zip(line, widths)).rstrip(), markup=False)
|
|
422
|
+
for line in pta.left_out_lines(result):
|
|
423
|
+
out.print()
|
|
424
|
+
out.print(line, markup=False)
|
|
420
425
|
|
|
421
426
|
|
|
422
427
|
def cmd_report(argv):
|
|
@@ -10,10 +10,10 @@ Run screen: live reasoning and answer, per-test ✓/✗, TTFT and tokens/s, p
|
|
|
10
10
|
k skips the current model, c cancels everything, f pauses auto-scroll.
|
|
11
11
|
r shows results at any time (also mid-run); n starts a new run when it ends.
|
|
12
12
|
Enter on a Recent results row shows that answer in full.
|
|
13
|
-
Results: opens on
|
|
14
|
-
question
|
|
15
|
-
|
|
16
|
-
answer, question.
|
|
13
|
+
Results: opens on the PTA index (parsimony, speed and accuracy per model, as bars). Per
|
|
14
|
+
question compares each model's tokens and seconds on the same question. ctrl+r
|
|
15
|
+
reloads (it also reloads when a run's pack finishes). Enter on a Failures row or a
|
|
16
|
+
Per question cell shows the answer: grading and checks, reasoning, answer, question.
|
|
17
17
|
Answer detail: [ and ] step to the previous/next answer; esc/q close.
|
|
18
18
|
Anywhere: esc/q go back (q quits only on Setup), ctrl+q quits, w lists this session's runs and
|
|
19
19
|
tunes. Leaving a live run or tune keeps it going; the header shows its progress.
|
|
@@ -331,11 +331,11 @@ with the model's reasoning and answer as they stream, then recent results with t
|
|
|
331
331
|
v with several models at a time: stream the next one (k skips the one streaming)""",
|
|
332
332
|
"ResultsScreen": """[b]Results[/b]
|
|
333
333
|
|
|
334
|
-
Opens on [b]
|
|
335
|
-
|
|
334
|
+
Opens on the [b]PTA index[/b]: parsimony (tokens), speed and accuracy per model, a bar each (0-100,
|
|
335
|
+
longer = better). [b]Production readiness[/b] gives one verdict per model and use case, and what's
|
|
336
|
+
missing when it's INCONCLUSIVE. The other tabs are the evidence:
|
|
336
337
|
Scorecard accuracy, critical failures and truncation per model and pack
|
|
337
338
|
Speed & tokens time and tokens per answer (★ = nothing beats it on both accuracy and time)
|
|
338
|
-
PTA index privacy, total time and accuracy per model (0-100 each), as a triangle
|
|
339
339
|
Per question every question, model by model
|
|
340
340
|
Is the difference real? whether one model is really better than another, or it's noise
|
|
341
341
|
Tests that separate the questions that tell models apart
|
|
@@ -2341,7 +2341,18 @@ class ResultsScreen(Screen):
|
|
|
2341
2341
|
yield Static(id="results-info")
|
|
2342
2342
|
if self.smoke_dir:
|
|
2343
2343
|
yield Button("Show this Smoke run's results", id="toggle-smoke")
|
|
2344
|
-
with TabbedContent(initial="tab-
|
|
2344
|
+
with TabbedContent(initial="tab-pta"):
|
|
2345
|
+
with TabPane("PTA index", id="tab-pta"):
|
|
2346
|
+
with Horizontal(id="pta-controls"):
|
|
2347
|
+
yield Select([("All packs", "")], id="pta-pack", allow_blank=False, value="")
|
|
2348
|
+
yield Button("Models: all", id="pta-models")
|
|
2349
|
+
yield Static("[dim]P parsimony: tokens used (reasoning and answer). T speed: total time. A accuracy: "
|
|
2350
|
+
"answers right. Longer bars are better for all three. Verdicts and critical failures "
|
|
2351
|
+
"are in Production readiness.[/dim]")
|
|
2352
|
+
yield Static(id="pta-note")
|
|
2353
|
+
yield Static(id="pta-bars")
|
|
2354
|
+
yield DataTable(id="pta", zebra_stripes=True, cursor_type="none")
|
|
2355
|
+
yield Static(id="pta-left-out")
|
|
2345
2356
|
with TabPane("Production readiness"):
|
|
2346
2357
|
yield Static(id="readiness-short")
|
|
2347
2358
|
yield Static(self.READINESS_HELP, id="readiness-help")
|
|
@@ -2366,19 +2377,6 @@ class ResultsScreen(Screen):
|
|
|
2366
2377
|
"On another machine, times are measured there if the model ran there, otherwise "
|
|
2367
2378
|
"projected from each answer's token counts and that machine's tuned speeds.[/dim]")
|
|
2368
2379
|
yield DataTable(id="speed", zebra_stripes=True)
|
|
2369
|
-
with TabPane("PTA index", id="tab-pta"):
|
|
2370
|
-
with Horizontal(id="pta-controls"):
|
|
2371
|
-
yield Select([("All packs", "")], id="pta-pack", allow_blank=False, value="")
|
|
2372
|
-
yield Button("Models: all", id="pta-models")
|
|
2373
|
-
yield Static("[dim]P privacy: 100 when prompts stay on machines you control (local, or a server "
|
|
2374
|
-
"marked private = true), 0 for a hosted API. T time: total time for every question "
|
|
2375
|
-
"compared, relative to the fastest model (100). A accuracy: answers right. Bigger "
|
|
2376
|
-
"triangle = better all round; verdicts and critical failures are in Production "
|
|
2377
|
-
"readiness.[/dim]")
|
|
2378
|
-
yield Static(id="pta-note")
|
|
2379
|
-
with Horizontal(id="pta-body"):
|
|
2380
|
-
yield Static(id="pta-triangle")
|
|
2381
|
-
yield DataTable(id="pta", zebra_stripes=True, cursor_type="none")
|
|
2382
2380
|
with TabPane("Per question", id="tab-per-question"):
|
|
2383
2381
|
with Horizontal(id="pq-controls"):
|
|
2384
2382
|
yield Select([("All packs", "")], id="pq-pack", allow_blank=False, value="")
|
|
@@ -2586,21 +2584,22 @@ class ResultsScreen(Screen):
|
|
|
2586
2584
|
present = {r["model"] for r in rows}
|
|
2587
2585
|
chosen = {m for m in self.app.pq_models if m in present}
|
|
2588
2586
|
self.query_one("#pta-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
|
|
2589
|
-
models = sorted(chosen or present)
|
|
2590
2587
|
t = self.query_one("#pta", DataTable)
|
|
2591
2588
|
t.clear(columns=True)
|
|
2592
|
-
if not
|
|
2589
|
+
if not present:
|
|
2593
2590
|
self.query_one("#pta-note", Static).update("No finished results to compare yet.")
|
|
2594
|
-
self.query_one("#pta-
|
|
2591
|
+
self.query_one("#pta-bars", Static).update("")
|
|
2592
|
+
self.query_one("#pta-left-out", Static).update("")
|
|
2595
2593
|
return
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
self.query_one("#pta-
|
|
2600
|
-
self.query_one("#pta-
|
|
2601
|
-
"\n".join(
|
|
2594
|
+
# none picked: every model with enough answers (pta.index leaves out the rest, and says so)
|
|
2595
|
+
result = pta.index(rows, sorted(chosen) or None)
|
|
2596
|
+
self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
|
|
2597
|
+
self.query_one("#pta-bars", Static).update("\n".join(pta.bars(result)) if result["questions"] else "")
|
|
2598
|
+
self.query_one("#pta-left-out", Static).update(
|
|
2599
|
+
"\n".join(f"[dim]{rich_escape(line)}[/dim]" for line in pta.left_out_lines(result)))
|
|
2602
2600
|
header, table = pta.table(result)
|
|
2603
|
-
|
|
2601
|
+
for i, h in enumerate(header): # sized to the contents: added before the rows, a column keeps its header's width
|
|
2602
|
+
t.add_column(h, width=max(len(h), *(len(r[i]) for r in table)) if table else None)
|
|
2604
2603
|
t.add_rows(table)
|
|
2605
2604
|
|
|
2606
2605
|
def fill_per_question(self):
|
|
@@ -2908,9 +2907,9 @@ class EvalsApp(App):
|
|
|
2908
2907
|
#pq-controls { height: 3; }
|
|
2909
2908
|
#pta-controls { height: 3; }
|
|
2910
2909
|
#pta-controls Select { width: 28; margin-right: 1; }
|
|
2911
|
-
#pta-
|
|
2912
|
-
#pta-triangle { width: auto; padding: 1 2 0 0; }
|
|
2910
|
+
#pta-bars { height: auto; padding: 1 0 0 0; }
|
|
2913
2911
|
#pta { width: 1fr; height: auto; margin-top: 1; }
|
|
2912
|
+
#pta-left-out { height: auto; padding: 1 0 0 0; }
|
|
2914
2913
|
#pq-controls Checkbox { width: auto; }
|
|
2915
2914
|
#pq-controls Select { width: 28; margin-right: 1; }
|
|
2916
2915
|
#pq-models { margin-right: 1; min-width: 20; }
|
|
@@ -18,6 +18,7 @@ import http.client
|
|
|
18
18
|
import itertools
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
|
+
import socketserver
|
|
21
22
|
import sys
|
|
22
23
|
import threading
|
|
23
24
|
import time
|
|
@@ -129,6 +130,12 @@ class Proxy:
|
|
|
129
130
|
class Server(ThreadingHTTPServer):
|
|
130
131
|
daemon_threads = True
|
|
131
132
|
|
|
133
|
+
def server_bind(self):
|
|
134
|
+
# HTTPServer looks up its own hostname here (socket.getfqdn), which can take half a
|
|
135
|
+
# minute where DNS is slow; the name is only cosmetic, so use the address.
|
|
136
|
+
socketserver.TCPServer.server_bind(self)
|
|
137
|
+
self.server_name, self.server_port = self.server_address[:2]
|
|
138
|
+
|
|
132
139
|
def handle_error(self, request, client_address):
|
|
133
140
|
err = sys.exc_info()[1]
|
|
134
141
|
if isinstance(err, (BrokenPipeError, ConnectionResetError)):
|
|
@@ -15,6 +15,7 @@ import json
|
|
|
15
15
|
import os
|
|
16
16
|
import sys
|
|
17
17
|
import time
|
|
18
|
+
import socketserver
|
|
18
19
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
20
|
|
|
20
21
|
import yaml
|
|
@@ -104,6 +105,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
104
105
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
105
106
|
|
|
106
107
|
|
|
108
|
+
class Server(ThreadingHTTPServer):
|
|
109
|
+
def server_bind(self):
|
|
110
|
+
# skip HTTPServer's own-hostname lookup (socket.getfqdn): 35 s per server on CI's macOS runners
|
|
111
|
+
socketserver.TCPServer.server_bind(self)
|
|
112
|
+
self.server_name, self.server_port = self.server_address[:2]
|
|
113
|
+
|
|
114
|
+
|
|
107
115
|
def main(argv=None):
|
|
108
116
|
p = argparse.ArgumentParser()
|
|
109
117
|
p.add_argument("--port", type=int, default=18090)
|
|
@@ -113,7 +121,7 @@ def main(argv=None):
|
|
|
113
121
|
p.add_argument("--delay", type=float, default=0.0, help="seconds each answer takes")
|
|
114
122
|
a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
|
|
115
123
|
Handler.answers, Handler.mode, Handler.model, Handler.delay = load_answers(a.packs), a.mode, a.model, a.delay
|
|
116
|
-
srv =
|
|
124
|
+
srv = Server(("127.0.0.1", a.port), Handler)
|
|
117
125
|
print(f"mock server on 127.0.0.1:{a.port} ({a.mode}, {len(Handler.answers)} known questions)", flush=True)
|
|
118
126
|
try:
|
|
119
127
|
srv.serve_forever()
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Uses a temporary workspace and tests/mock_server.py on a free local port; no model or GPU needed.
|
|
4
4
|
"""
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import socket
|
|
@@ -702,40 +703,59 @@ class AddToRun(unittest.TestCase):
|
|
|
702
703
|
|
|
703
704
|
|
|
704
705
|
class PTAIndex(unittest.TestCase):
|
|
705
|
-
"""The PTA index:
|
|
706
|
-
all over the questions every compared model answered."""
|
|
707
|
-
|
|
708
|
-
def test_privacy(self):
|
|
709
|
-
from tuieval import pta
|
|
710
|
-
cfg = {"servers": {"llama": {"cmd": ["llama-server"]}, "lm": {"url": "http://localhost:1234/v1"},
|
|
711
|
-
"box": {"url": "http://192.168.1.20:8000/v1"}, "mine": {"url": "http://192.168.1.20:8000/v1",
|
|
712
|
-
"private": True},
|
|
713
|
-
"or": {"url": "https://openrouter.ai/api/v1"}},
|
|
714
|
-
"models": [{"label": l, "server": l} for l in ("llama", "lm", "box", "mine", "or")]}
|
|
715
|
-
self.assertEqual([pta.privacy(cfg, l) for l in ("llama", "lm", "box", "mine", "or", "gone")],
|
|
716
|
-
[100, 100, 0, 100, 0, None])
|
|
706
|
+
"""The PTA index: parsimony from tokens and speed from time, each relative to the best on a log
|
|
707
|
+
scale, and accuracy, all over the questions every compared model answered."""
|
|
717
708
|
|
|
718
709
|
def test_index_compares_shared_questions(self):
|
|
719
710
|
from tuieval import pta
|
|
720
711
|
|
|
721
712
|
def row(model, test, ok, secs, repeat=0):
|
|
722
713
|
return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "latency": secs * 1000,
|
|
723
|
-
"tokens": 10, "truncated": False, "gen_tps": None, "repeat": repeat}
|
|
714
|
+
"tokens": 10 if model == "slow" else 40, "truncated": False, "gen_tps": None, "repeat": repeat}
|
|
724
715
|
rows = [row("fast", "q1", True, 2), row("fast", "q2", False, 2),
|
|
725
716
|
row("slow", "q1", True, 8), row("slow", "q2", True, 4), row("slow", "q2", True, 6, repeat=1),
|
|
726
717
|
row("slow", "q3", True, 100)] # only slow answered q3: not compared
|
|
727
|
-
res = pta.index(rows
|
|
718
|
+
res = pta.index(rows)
|
|
728
719
|
self.assertEqual(res["questions"], 2)
|
|
729
720
|
by = {x["model"]: x for x in res["models"]}
|
|
730
721
|
self.assertEqual(by["fast"]["total_s"], 4)
|
|
731
722
|
self.assertEqual(by["slow"]["total_s"], 13) # q1 8 + q2 median of 4 and 6
|
|
732
723
|
self.assertEqual(by["fast"]["T"], 100)
|
|
733
|
-
self.assertAlmostEqual(by["slow"]["T"], 100 *
|
|
724
|
+
self.assertAlmostEqual(by["slow"]["T"], 100 - 15 * math.log2(13 / 4)) # 15 points per doubling
|
|
725
|
+
self.assertEqual((by["slow"]["tokens"], by["fast"]["tokens"]), (20, 80)) # medians over repeats
|
|
726
|
+
self.assertEqual((by["slow"]["P"], by["fast"]["P"]), (100, 70)) # 4x the tokens: two doublings
|
|
727
|
+
self.assertEqual((pta.score(2, 1), pta.score(1000, 1)), (85, 0))
|
|
728
|
+
header, table = pta.table(res)
|
|
729
|
+
self.assertEqual(header[1:4], ["P parsimony /100", "T speed /100", "A accuracy %"])
|
|
730
|
+
cells = {r[0]: r for r in table}
|
|
731
|
+
self.assertEqual(cells["fast"][1:4], ["70", "100", "50%"])
|
|
732
|
+
self.assertEqual((cells["slow"][4], cells["fast"][5][-1]), ("20 ★", "★")) # fewest tokens, fastest
|
|
733
|
+
bars = pta.bars(res, 10, markup=False)
|
|
734
|
+
self.assertEqual(bars[0].split(), ["P", "parsimony", "T", "speed", "A", "accuracy"])
|
|
735
|
+
self.assertEqual(bars[2], "fast " + "███████░░░ 70 " + "██████████ 100 " + "█████░░░░░ 50%")
|
|
734
736
|
self.assertEqual((by["fast"]["A"], by["slow"]["A"]), (50, 100))
|
|
735
737
|
self.assertEqual([x["model"] for x in res["models"]], ["slow", "fast"]) # best accuracy first
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
738
|
+
|
|
739
|
+
def test_server_errors_and_models_with_few_answers(self):
|
|
740
|
+
from tuieval import pta
|
|
741
|
+
|
|
742
|
+
def row(model, test, ok=True, error=False):
|
|
743
|
+
return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "error": error,
|
|
744
|
+
"latency": 1000, "tokens": 10, "truncated": False, "gen_tps": None, "repeat": 0}
|
|
745
|
+
rows = ([row(m, f"q{i}") for m in ("a", "b") for i in range(10)] + [row("few", "q0", ok=False)]
|
|
746
|
+
+ [row("down", f"q{i}", ok=False, error=True) for i in range(10)]
|
|
747
|
+
+ [row("b", "q10", ok=False, error=True)]) # an error among real answers: not counted
|
|
748
|
+
res = pta.index(rows)
|
|
749
|
+
self.assertEqual([x["model"] for x in res["models"]], ["a", "b"])
|
|
750
|
+
self.assertEqual(res["questions"], 10) # not shrunk to the one "few" answered
|
|
751
|
+
self.assertEqual(res["left_out"], [("few", 1)])
|
|
752
|
+
self.assertEqual(res["no_answers"], ["down"])
|
|
753
|
+
self.assertEqual([x["answers"] for x in res["models"]], [10, 10])
|
|
754
|
+
legend = "\n".join(pta.left_out_lines(res))
|
|
755
|
+
self.assertIn("few (1 of 10)", legend)
|
|
756
|
+
self.assertIn("only server errors: down", legend)
|
|
757
|
+
picked = pta.index(rows, ["a", "few"]) # picked by hand: compared anyway
|
|
758
|
+
self.assertEqual((picked["questions"], picked["left_out"]), (1, []))
|
|
739
759
|
|
|
740
760
|
def test_cli_report_and_tui(self):
|
|
741
761
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -759,16 +779,14 @@ class PTAIndex(unittest.TestCase):
|
|
|
759
779
|
finally:
|
|
760
780
|
right.stop()
|
|
761
781
|
wrong.stop()
|
|
762
|
-
path = os.path.join(ws, "models.toml") # as if "there" were a hosted API
|
|
763
|
-
write(path, read(path).replace(f"http://127.0.0.1:{wrong.port}", "https://api.example.com/v1"))
|
|
764
782
|
out = tuieval(ws, "pta").stdout
|
|
765
783
|
self.assertIn("Compared on the 4 questions all 2 model(s) answered", out)
|
|
766
784
|
table = {l.split()[0]: l.split()[1:4] for l in out.splitlines() if l.startswith(("good ", "hosted "))}
|
|
767
|
-
self.assertEqual(table["good"], ["100", "100", "100"])
|
|
768
|
-
self.assertEqual(table["hosted"][0], "
|
|
785
|
+
self.assertEqual(table["good"], ["100", "100", "100%"])
|
|
786
|
+
self.assertEqual(table["hosted"][0], "100") # the mock says as much either way
|
|
769
787
|
self.assertLess(int(table["hosted"][1]), 100) # slower in total
|
|
770
|
-
self.assertEqual(table["hosted"][2], "0")
|
|
771
|
-
self.assertIn("P
|
|
788
|
+
self.assertEqual(table["hosted"][2], "0%")
|
|
789
|
+
self.assertIn("P parsimony", out)
|
|
772
790
|
self.assertIn("4/4", tuieval(ws, "pta", "--only", "good").stdout)
|
|
773
791
|
report = os.path.join(tmp, "r.md")
|
|
774
792
|
tuieval(ws, "report", "-o", report)
|
|
@@ -776,7 +794,7 @@ class PTAIndex(unittest.TestCase):
|
|
|
776
794
|
code = textwrap.dedent("""
|
|
777
795
|
import asyncio, json
|
|
778
796
|
from tuieval.tui import EvalsApp, ResultsScreen
|
|
779
|
-
from textual.widgets import DataTable, Static
|
|
797
|
+
from textual.widgets import DataTable, Static, TabbedContent
|
|
780
798
|
async def go():
|
|
781
799
|
app = EvalsApp({})
|
|
782
800
|
async with app.run_test(size=(180, 60)) as pilot:
|
|
@@ -785,16 +803,18 @@ class PTAIndex(unittest.TestCase):
|
|
|
785
803
|
await pilot.pause(0.5)
|
|
786
804
|
t = app.screen.query_one("#pta", DataTable)
|
|
787
805
|
rows = [[str(c) for c in t.get_row_at(i)] for i in range(t.row_count)]
|
|
788
|
-
tri = str(app.screen.query_one("#pta-
|
|
789
|
-
print(json.dumps({"rows": rows, "
|
|
806
|
+
tri = str(app.screen.query_one("#pta-bars", Static).render())
|
|
807
|
+
print(json.dumps({"rows": rows, "bars": "P parsimony" in tri,
|
|
808
|
+
"tab": app.screen.query_one(TabbedContent).active}))
|
|
790
809
|
asyncio.run(go())
|
|
791
810
|
""")
|
|
792
811
|
p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
|
|
793
812
|
env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""))
|
|
794
813
|
got = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
|
|
795
814
|
self.assertIsInstance(got, dict, got)
|
|
796
|
-
self.assertTrue(got["
|
|
797
|
-
self.assertEqual(
|
|
815
|
+
self.assertTrue(got["bars"])
|
|
816
|
+
self.assertEqual(got["tab"], "tab-pta") # Results opens on the PTA index
|
|
817
|
+
self.assertEqual([r[:4] for r in got["rows"]][0], ["good", "100", "100", "100%"])
|
|
798
818
|
|
|
799
819
|
|
|
800
820
|
class Unavailable(unittest.TestCase):
|
|
@@ -889,7 +909,7 @@ class FirstRun(unittest.TestCase):
|
|
|
889
909
|
env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
|
|
890
910
|
self.assertEqual(out, "['first'] ['my-model'] smoke False") # tuning shows once something has run
|
|
891
911
|
|
|
892
|
-
def
|
|
912
|
+
def test_results_open_on_pta_index(self):
|
|
893
913
|
tuieval(self.ws, "add", "my-model", "--server", "local")
|
|
894
914
|
code = textwrap.dedent("""
|
|
895
915
|
import asyncio
|
|
@@ -906,7 +926,7 @@ class FirstRun(unittest.TestCase):
|
|
|
906
926
|
""")
|
|
907
927
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
|
908
928
|
env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
|
|
909
|
-
self.assertEqual(out, "ResultsScreen tab-
|
|
929
|
+
self.assertEqual(out, "ResultsScreen tab-pta")
|
|
910
930
|
|
|
911
931
|
def test_stream_follows_only_at_the_bottom(self):
|
|
912
932
|
code = textwrap.dedent("""
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
"""PTA index: privacy, time and accuracy per model, 0-100 each, drawn as a radar triangle.
|
|
2
|
-
|
|
3
|
-
P privacy 100 when prompts stay on machines you control: tuieval starts the server, its URL
|
|
4
|
-
is this machine, or the server is marked private = true in models.toml. 0 otherwise
|
|
5
|
-
(a hosted API: there's no partial privacy once prompts leave).
|
|
6
|
-
T time the total time to answer every question compared (each question at its median over
|
|
7
|
-
repeats), relative to the fastest model: 100 x fastest total / this model's total.
|
|
8
|
-
A accuracy the share of answers to those questions that passed.
|
|
9
|
-
|
|
10
|
-
Models are compared on the questions all of them answered, so one that skipped some never looks
|
|
11
|
-
faster. There's no combined number: the three stay side by side, and verdicts and critical
|
|
12
|
-
failures stay where they are (verdict.py).
|
|
13
|
-
"""
|
|
14
|
-
import urllib.parse
|
|
15
|
-
|
|
16
|
-
from . import compare
|
|
17
|
-
|
|
18
|
-
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
|
19
|
-
# one colour per model on the triangle; more models than colours are listed but not drawn
|
|
20
|
-
COLORS = ["cyan", "magenta", "yellow", "green", "red", "blue", "bright_white", "bright_cyan"]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def privacy(cfg, label):
|
|
24
|
-
"""100 if a model's prompts stay on machines you control, else 0 (see the module docstring);
|
|
25
|
-
None for a model that's no longer in models.toml (where it ran isn't known)."""
|
|
26
|
-
m = next((m for m in cfg["models"] if m["label"] == label), None)
|
|
27
|
-
server = cfg["servers"].get(m["server"], {}) if m else {}
|
|
28
|
-
if not server:
|
|
29
|
-
return None
|
|
30
|
-
if server.get("cmd") or server.get("private"):
|
|
31
|
-
return 100
|
|
32
|
-
host = urllib.parse.urlparse(server.get("url", "")).hostname or ""
|
|
33
|
-
return 100 if host.lower() in LOCAL_HOSTS else 0
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def index(rows, privacy_of, models=None):
|
|
37
|
-
"""{"questions": n shared, "models": [{model, P, T, A, total_s, passed, answers, alongside}]},
|
|
38
|
-
best accuracy first. models: the ones to compare (default: every model in rows)."""
|
|
39
|
-
tests = compare.per_question(rows)
|
|
40
|
-
models = sorted(models or {m for t in tests for m in t["cells"]})
|
|
41
|
-
shared = [t for t in tests if models and all(m in t["cells"] for m in models)]
|
|
42
|
-
out = []
|
|
43
|
-
for m in models:
|
|
44
|
-
cells = [t["cells"][m] for t in shared]
|
|
45
|
-
answers = sum(c["n"] for c in cells)
|
|
46
|
-
out.append({"model": m, "P": privacy_of(m), "total_s": sum(c["secs"] or 0 for c in cells),
|
|
47
|
-
"passed": sum(c["passed"] for c in cells), "answers": answers,
|
|
48
|
-
"A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
|
|
49
|
-
# answers that ran while other models were served shared the machine's speed
|
|
50
|
-
"alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
|
|
51
|
-
fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
|
|
52
|
-
for x in out:
|
|
53
|
-
x["T"] = 100 * fastest / x["total_s"] if fastest and x["total_s"] > 0 else None
|
|
54
|
-
out.sort(key=lambda x: (-(x["A"] or 0), -(x["T"] or 0), x["model"]))
|
|
55
|
-
return {"questions": len(shared), "models": out}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# ---------------------------------------------------------------- the triangle (braille, no dependencies)
|
|
59
|
-
_DOTS = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80)) # braille dot bits by (row, column)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class _Canvas:
|
|
63
|
-
"""Braille cells, 2 dots wide and 4 tall each; every cell takes the colour of the last line through it."""
|
|
64
|
-
def __init__(self, cols, rows):
|
|
65
|
-
self.cols, self.rows = cols, rows
|
|
66
|
-
self.bits = [[0] * cols for _ in range(rows)]
|
|
67
|
-
self.color = [[None] * cols for _ in range(rows)]
|
|
68
|
-
|
|
69
|
-
def dot(self, x, y, color):
|
|
70
|
-
cx, cy = int(x) // 2, int(y) // 4
|
|
71
|
-
if 0 <= cx < self.cols and 0 <= cy < self.rows:
|
|
72
|
-
self.bits[cy][cx] |= _DOTS[int(y) % 4][int(x) % 2]
|
|
73
|
-
self.color[cy][cx] = color
|
|
74
|
-
|
|
75
|
-
def line(self, a, b, color, dotted=False):
|
|
76
|
-
steps = int(max(abs(b[0] - a[0]), abs(b[1] - a[1]))) or 1
|
|
77
|
-
for i in range(steps + 1):
|
|
78
|
-
if dotted and i % 3:
|
|
79
|
-
continue
|
|
80
|
-
self.dot(a[0] + (b[0] - a[0]) * i / steps, a[1] + (b[1] - a[1]) * i / steps, color)
|
|
81
|
-
|
|
82
|
-
def text(self):
|
|
83
|
-
lines = []
|
|
84
|
-
for bits, colors in zip(self.bits, self.color):
|
|
85
|
-
out = []
|
|
86
|
-
for b, c in zip(bits, colors):
|
|
87
|
-
ch = chr(0x2800 + b) if b else " "
|
|
88
|
-
out.append(f"[{c}]{ch}[/]" if b and c else ch)
|
|
89
|
-
lines.append("".join(out).rstrip())
|
|
90
|
-
return lines
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def triangle(result, width=60):
|
|
94
|
-
"""The radar triangle as Rich markup lines: P at the top, T bottom left, A bottom right; a
|
|
95
|
-
model's triangle reaches each corner as far as its score (bigger = better all round)."""
|
|
96
|
-
cols = max(20, width)
|
|
97
|
-
w = cols * 2 - 2 # dots across
|
|
98
|
-
h = int(w * 0.866) # an equilateral triangle (braille dots are about square)
|
|
99
|
-
rows = h // 4 + 1
|
|
100
|
-
top, left, right = (w / 2, 0), (0, h), (w, h)
|
|
101
|
-
center = (w / 2, h * 2 / 3)
|
|
102
|
-
c = _Canvas(cols, rows)
|
|
103
|
-
|
|
104
|
-
def at(corner, score):
|
|
105
|
-
return (center[0] + (corner[0] - center[0]) * score / 100, center[1] + (corner[1] - center[1]) * score / 100)
|
|
106
|
-
|
|
107
|
-
for corner in (top, left, right): # axes, the 50% ring, then the frame
|
|
108
|
-
c.line(center, corner, "grey37", dotted=True)
|
|
109
|
-
half = [at(k, 50) for k in (top, left, right)]
|
|
110
|
-
for a, b in zip(half, half[1:] + half[:1]):
|
|
111
|
-
c.line(a, b, "grey37", dotted=True)
|
|
112
|
-
for a, b in ((top, left), (left, right), (right, top)):
|
|
113
|
-
c.line(a, b, "grey70")
|
|
114
|
-
for i, x in enumerate(result["models"][:len(COLORS)][::-1]): # the best ends on top
|
|
115
|
-
color = COLORS[len(result["models"][:len(COLORS)]) - 1 - i]
|
|
116
|
-
pts = [at(k, s or 0) for k, s in ((top, x["P"]), (left, x["T"]), (right, x["A"]))]
|
|
117
|
-
for a, b in zip(pts, pts[1:] + pts[:1]):
|
|
118
|
-
c.line(a, b, color)
|
|
119
|
-
lines = c.text()
|
|
120
|
-
pad = " " * max(0, cols // 2 - 5)
|
|
121
|
-
return ([f"{pad}[b]P[/b] privacy"] + lines
|
|
122
|
-
+ [f"[b]T[/b] time{' ' * max(1, cols - 15)}[b]A[/b] accuracy"])
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def legend(result):
|
|
126
|
-
"""One Rich markup line per model: its colour on the triangle and its three scores."""
|
|
127
|
-
out = []
|
|
128
|
-
for i, x in enumerate(result["models"]):
|
|
129
|
-
mark = f"[{COLORS[i]}]■[/]" if i < len(COLORS) else " "
|
|
130
|
-
out.append(f"{mark} {x['model']} P {_score(x['P'])} · T {_score(x['T'])} · A {_score(x['A'])}"
|
|
131
|
-
+ (" [yellow](some answers ran alongside other models)[/yellow]" if x["alongside"] else ""))
|
|
132
|
-
return out
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _score(v):
|
|
136
|
-
return "-" if v is None else f"{v:.0f}"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def table(result):
|
|
140
|
-
"""(header, rows) for a plain table of the index."""
|
|
141
|
-
header = ["model", "P privacy", "T time", "A accuracy", "total time", "answers right"]
|
|
142
|
-
rows = [[x["model"], _score(x["P"]), _score(x["T"]), _score(x["A"]), fmt_total(x["total_s"]),
|
|
143
|
-
f"{x['passed']}/{x['answers']}"] for x in result["models"]]
|
|
144
|
-
return header, rows
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def fmt_total(secs):
|
|
148
|
-
if not secs:
|
|
149
|
-
return "-"
|
|
150
|
-
if secs < 60:
|
|
151
|
-
return f"{secs:.1f}s"
|
|
152
|
-
secs = round(secs)
|
|
153
|
-
h, rem = divmod(secs, 3600)
|
|
154
|
-
return f"{h}h{rem // 60:02d}m" if h else f"{rem // 60}m{rem % 60:02d}s" if rem >= 60 else f"{rem}s"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def scope_note(result, models):
|
|
158
|
-
"""What the scores compare, in a sentence."""
|
|
159
|
-
if not result["questions"]:
|
|
160
|
-
return "These models have no question in common yet, so there's nothing to compare."
|
|
161
|
-
return (f"Compared on the {result['questions']} questions all {len(models)} model(s) answered. "
|
|
162
|
-
"T is relative to the fastest total, so adding a faster model lowers the others.")
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def for_engine(e, labels=None, packs=None, results_dir=None):
|
|
166
|
-
"""(result, models) from a workspace's finished results (results on their way out excluded)."""
|
|
167
|
-
paths = [p for p in compare.default_paths(results_dir or e.results_dir) if not e.superseded(p)]
|
|
168
|
-
rows, _ = compare.load(paths) if paths else ([], [])
|
|
169
|
-
if packs:
|
|
170
|
-
rows = [r for r in rows if r["suite"] in packs]
|
|
171
|
-
models = sorted({r["model"] for r in rows} & set(labels) if labels else {r["model"] for r in rows})
|
|
172
|
-
return index(rows, lambda m: privacy(e.cfg, m), models), models
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def markdown(result, models):
|
|
176
|
-
"""The index as a markdown section (tuieval report)."""
|
|
177
|
-
lines = ["## PTA index", "", scope_note(result, models), ""]
|
|
178
|
-
if not result["questions"]:
|
|
179
|
-
return lines
|
|
180
|
-
header, rows = table(result)
|
|
181
|
-
lines += ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
|
|
182
|
-
lines += ["| " + " | ".join(r) + " |" for r in rows]
|
|
183
|
-
if any(x["alongside"] for x in result["models"]):
|
|
184
|
-
lines += ["", "Some answers ran alongside other models, which shared the machine's speed."]
|
|
185
|
-
return lines
|
|
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.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/tools.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|