tuieval 0.2.0.dev66__tar.gz → 0.2.0.dev70__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.dev66 → tuieval-0.2.0.dev70}/CHANGELOG.md +2 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/PKG-INFO +4 -2
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/README.md +3 -1
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/cli.py +4 -0
- tuieval-0.2.0.dev70/src/tuieval/logs.py +220 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/tui.py +130 -7
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/tests/test_tuieval.py +129 -1
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/.gitignore +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/LICENSE +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/models.md +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/builtin.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/client.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/compare.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/engine.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/pta.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/run_evals.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/yamlout.py +0 -0
- {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/tests/mock_server.py +0 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- `tuieval logs` follows the server log of the model running now and moves on with the run; `logs live` shows each answer as it finishes, `--list` every log.
|
|
6
|
+
- Results tables sort by a click on a column name; a second click reverses. Numbers, times and levels sort by value; empty cells stay last.
|
|
5
7
|
- Nvidia and AMD GPUs on Linux: VRAM from `nvidia-smi`, `amd-smi` or `rocm-smi` sizes models (`gpu_memory_gb` overrides it), runs record peak VRAM, and `tuieval doctor` shows the GPU.
|
|
6
8
|
- Results has four tabs: PTA index, Production readiness, Per question and Failures. The PTA table adds tok/s, TTFT and memory; Per question can show only where models disagree; the settings warning is one line. Scorecard, pairwise, history and test quality stay in `tuieval compare`, `history` and `items`.
|
|
7
9
|
- The run's ETA adds up each pack's answers left × that model's time per answer there (its history, then its own times once it has 5 answers), instead of extrapolating from the first few answers.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev70
|
|
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
|
|
@@ -144,7 +144,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
|
|
|
144
144
|
1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
|
|
145
145
|
2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
|
|
146
146
|
3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
|
|
147
|
-
4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
|
|
147
|
+
4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures; click a column name to sort. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
|
|
148
148
|
|
|
149
149
|
Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
|
|
150
150
|
|
|
@@ -162,6 +162,8 @@ tuieval history # every verdict change over time
|
|
|
162
162
|
tuieval compare --speed --pairwise --failures # scorecards
|
|
163
163
|
tuieval selftest # check every test's reference and wrong answers
|
|
164
164
|
tuieval items # tests that don't separate models or look broken
|
|
165
|
+
tuieval logs # follow the server log of the model running now
|
|
166
|
+
tuieval logs live # each answer as it finishes (--list: every log)
|
|
165
167
|
tuieval capture logs/live/<file> --pack X # turn a real failure into a new test
|
|
166
168
|
tuieval regrade # re-score stored answers after changing a grader
|
|
167
169
|
tuieval regrade --only <model> --packs X # just one model's results (and packs)
|
|
@@ -118,7 +118,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
|
|
|
118
118
|
1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
|
|
119
119
|
2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
|
|
120
120
|
3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
|
|
121
|
-
4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
|
|
121
|
+
4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures; click a column name to sort. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
|
|
122
122
|
|
|
123
123
|
Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
|
|
124
124
|
|
|
@@ -136,6 +136,8 @@ tuieval history # every verdict change over time
|
|
|
136
136
|
tuieval compare --speed --pairwise --failures # scorecards
|
|
137
137
|
tuieval selftest # check every test's reference and wrong answers
|
|
138
138
|
tuieval items # tests that don't separate models or look broken
|
|
139
|
+
tuieval logs # follow the server log of the model running now
|
|
140
|
+
tuieval logs live # each answer as it finishes (--list: every log)
|
|
139
141
|
tuieval capture logs/live/<file> --pack X # turn a real failure into a new test
|
|
140
142
|
tuieval regrade # re-score stored answers after changing a grader
|
|
141
143
|
tuieval regrade --only <model> --packs X # just one model's results (and packs)
|
|
@@ -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.dev70'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev70')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -18,6 +18,7 @@ Day to day
|
|
|
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
|
|
21
|
+
tuieval logs follow the server log of the model running now (live: its answers)
|
|
21
22
|
tuieval capture LOG --pack turn a real failure (logs/live/*.txt) into a new test
|
|
22
23
|
tuieval regrade re-score stored answers after changing a grader (--only, --packs)
|
|
23
24
|
tuieval list the models tuieval knows, hidden ones separately (--packs: the packs)
|
|
@@ -85,6 +86,9 @@ def main(argv=None):
|
|
|
85
86
|
if cmd == "watch":
|
|
86
87
|
from . import watch_proxy
|
|
87
88
|
return watch_proxy.main(rest)
|
|
89
|
+
if cmd == "logs":
|
|
90
|
+
from . import logs
|
|
91
|
+
return logs.main(rest)
|
|
88
92
|
from . import run_evals
|
|
89
93
|
if cmd in run_evals.COMMANDS:
|
|
90
94
|
return run_evals.COMMANDS[cmd](rest)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""tuieval logs: follow the server log of the model running now, without naming it.
|
|
2
|
+
|
|
3
|
+
tuieval logs the newest server log, then every log that grows (a header says
|
|
4
|
+
which model each part is from), so it moves on with the run
|
|
5
|
+
tuieval logs live each answer (reasoning and answer, from logs/live/) as it finishes
|
|
6
|
+
tuieval logs --list every model's server log, newest first
|
|
7
|
+
tuieval logs MODEL one model's log (any unambiguous part of its label)
|
|
8
|
+
|
|
9
|
+
Read-only: it never touches servers or results. Only servers tuieval starts write a log here;
|
|
10
|
+
OpenRouter and servers that were already running don't.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import workspace
|
|
20
|
+
|
|
21
|
+
POLL_S = 0.5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def server_dir():
|
|
25
|
+
return workspace.path("logs", "server")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def live_dir():
|
|
29
|
+
return workspace.path("logs", "live")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _files(d, ext):
|
|
33
|
+
"""{path: (size, mtime)} for the files in d with this extension (none if d doesn't exist)."""
|
|
34
|
+
out = {}
|
|
35
|
+
try:
|
|
36
|
+
names = os.listdir(d)
|
|
37
|
+
except OSError:
|
|
38
|
+
return out
|
|
39
|
+
for n in names:
|
|
40
|
+
if n.endswith(ext):
|
|
41
|
+
p = os.path.join(d, n)
|
|
42
|
+
try:
|
|
43
|
+
st = os.stat(p)
|
|
44
|
+
except OSError:
|
|
45
|
+
continue
|
|
46
|
+
out[p] = (st.st_size, st.st_mtime)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _label(path):
|
|
51
|
+
return os.path.basename(path)[:-len(".log")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _tail_offset(path, lines):
|
|
55
|
+
"""Where the last `lines` lines of a file start."""
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
f.seek(0, os.SEEK_END)
|
|
58
|
+
end = pos = f.tell()
|
|
59
|
+
found, chunk = 0, 8192
|
|
60
|
+
while pos > 0 and found <= lines:
|
|
61
|
+
pos = max(0, pos - chunk)
|
|
62
|
+
f.seek(pos)
|
|
63
|
+
found = f.read(end - pos).count(b"\n")
|
|
64
|
+
if pos == 0 and found <= lines:
|
|
65
|
+
return 0
|
|
66
|
+
f.seek(pos)
|
|
67
|
+
data = f.read(end - pos)
|
|
68
|
+
cut = len(data)
|
|
69
|
+
for _ in range(lines + 1):
|
|
70
|
+
cut = data.rfind(b"\n", 0, cut)
|
|
71
|
+
return pos + cut + 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Follower:
|
|
75
|
+
"""Follows server logs. `poll()` returns the text written since the last poll, with a
|
|
76
|
+
`── label ──` header whenever it comes from another log than the text before it.
|
|
77
|
+
only: follow just this log. Otherwise every log in the folder, including new ones."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, folder, lines=40, only=None):
|
|
80
|
+
self.folder, self.only = folder, only
|
|
81
|
+
self.offsets = {p: size for p, (size, _) in _files(folder, ".log").items()}
|
|
82
|
+
self.last = None # the log the previous text came from
|
|
83
|
+
self.held = {} # path -> size at the last poll, for a line still being written
|
|
84
|
+
start = only or max(self.offsets, key=lambda p: os.path.getmtime(p), default=None)
|
|
85
|
+
if start and start in self.offsets:
|
|
86
|
+
self.offsets[start] = _tail_offset(start, lines)
|
|
87
|
+
|
|
88
|
+
def poll(self):
|
|
89
|
+
out = []
|
|
90
|
+
files = _files(self.folder, ".log")
|
|
91
|
+
if self.only:
|
|
92
|
+
files = {p: v for p, v in files.items() if p == self.only}
|
|
93
|
+
for path, (size, _) in sorted(files.items(), key=lambda kv: kv[1][1]): # oldest change first
|
|
94
|
+
off = self.offsets.setdefault(path, 0)
|
|
95
|
+
if size < off: # truncated or replaced: read it from the start
|
|
96
|
+
off = 0
|
|
97
|
+
if size == off:
|
|
98
|
+
continue
|
|
99
|
+
with open(path, "rb") as f:
|
|
100
|
+
f.seek(off)
|
|
101
|
+
data = f.read(size - off)
|
|
102
|
+
cut = data.rfind(b"\n") + 1
|
|
103
|
+
if not cut: # no complete line yet: wait one poll for the rest of it
|
|
104
|
+
if self.held.get(path) != size:
|
|
105
|
+
self.held[path] = size
|
|
106
|
+
continue
|
|
107
|
+
cut = len(data)
|
|
108
|
+
self.held.pop(path, None)
|
|
109
|
+
self.offsets[path] = off + cut
|
|
110
|
+
if path != self.last:
|
|
111
|
+
out.append(f"── {_label(path)} ──\n")
|
|
112
|
+
self.last = path
|
|
113
|
+
out.append(data[:cut].decode("utf-8", "replace"))
|
|
114
|
+
return "".join(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
FOOTER = re.compile(r"^=== .*finish=.* ===$", re.M)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class LiveFollower:
|
|
121
|
+
"""New answers in logs/live/ as they're written: each one from its reasoning on (the prompt is
|
|
122
|
+
in the file), under a header with the file name."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, folder, last=1):
|
|
125
|
+
self.folder = folder
|
|
126
|
+
files = sorted(_files(folder, ".txt").items(), key=lambda kv: kv[1][1])
|
|
127
|
+
self.seen = {p for p, _ in files[:max(0, len(files) - last)]}
|
|
128
|
+
|
|
129
|
+
def poll(self):
|
|
130
|
+
out = []
|
|
131
|
+
for path, (_, mtime) in sorted(_files(self.folder, ".txt").items(), key=lambda kv: kv[1][1]):
|
|
132
|
+
if path in self.seen:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
with open(path, errors="replace") as f:
|
|
136
|
+
text = f.read()
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
if not FOOTER.search(text) and time.time() - mtime < 2: # still being written
|
|
140
|
+
continue
|
|
141
|
+
self.seen.add(path)
|
|
142
|
+
i = text.find("=== REASONING ===")
|
|
143
|
+
out.append(f"── {os.path.basename(path)[:-4]} ──\n{text[i:] if i >= 0 else text}\n")
|
|
144
|
+
return "".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ago(mtime):
|
|
148
|
+
s = max(0, int(time.time() - mtime))
|
|
149
|
+
if s < 60:
|
|
150
|
+
return f"{s}s ago"
|
|
151
|
+
if s < 3600:
|
|
152
|
+
return f"{s // 60} min ago"
|
|
153
|
+
if s < 86400:
|
|
154
|
+
return f"{s // 3600} h ago"
|
|
155
|
+
return datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _size(n):
|
|
159
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
160
|
+
if n < 1024 or unit == "GB":
|
|
161
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
162
|
+
n /= 1024
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_logs(folder):
|
|
166
|
+
files = sorted(_files(folder, ".log").items(), key=lambda kv: -kv[1][1])
|
|
167
|
+
if not files:
|
|
168
|
+
return f"No server logs yet in {os.path.relpath(folder)}/."
|
|
169
|
+
w = max(len(_label(p)) for p, _ in files)
|
|
170
|
+
return "\n".join(f"{_label(p):<{w}} {_size(size):>9} {_ago(mtime)}" for p, (size, mtime) in files)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def find_log(folder, name):
|
|
174
|
+
"""The log whose label is `name`, or the only one containing it. Raises SystemExit otherwise."""
|
|
175
|
+
labels = {_label(p): p for p in _files(folder, ".log")}
|
|
176
|
+
if name in labels:
|
|
177
|
+
return labels[name]
|
|
178
|
+
hits = sorted(l for l in labels if name.lower() in l.lower())
|
|
179
|
+
if len(hits) == 1:
|
|
180
|
+
return labels[hits[0]]
|
|
181
|
+
if not hits:
|
|
182
|
+
sys.exit(f"No server log matches {name!r}; tuieval logs --list shows them.")
|
|
183
|
+
sys.exit(f"{name!r} matches {len(hits)} logs; say which:\n " + "\n ".join(hits))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def follow(follower, waiting):
|
|
187
|
+
"""Print what the follower finds until ctrl-c."""
|
|
188
|
+
said = False
|
|
189
|
+
try:
|
|
190
|
+
while True:
|
|
191
|
+
text = follower.poll()
|
|
192
|
+
if text:
|
|
193
|
+
sys.stdout.write(text)
|
|
194
|
+
sys.stdout.flush()
|
|
195
|
+
said = True
|
|
196
|
+
elif not said:
|
|
197
|
+
print(waiting, flush=True)
|
|
198
|
+
said = True
|
|
199
|
+
time.sleep(POLL_S)
|
|
200
|
+
except KeyboardInterrupt:
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv):
|
|
205
|
+
p = argparse.ArgumentParser(prog="tuieval logs", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
206
|
+
epilog="ctrl-c stops following (a run going on isn't affected).")
|
|
207
|
+
p.add_argument("what", nargs="?", help="'live' for answers, or a model (any unambiguous part of its label)")
|
|
208
|
+
p.add_argument("--list", action="store_true", help="every model's server log, newest first")
|
|
209
|
+
p.add_argument("-n", "--lines", type=int, default=40, help="lines shown before following (default 40)")
|
|
210
|
+
a = p.parse_args(argv)
|
|
211
|
+
if a.list:
|
|
212
|
+
print(list_logs(server_dir()))
|
|
213
|
+
return 0
|
|
214
|
+
if a.what == "live":
|
|
215
|
+
return follow(LiveFollower(live_dir()),
|
|
216
|
+
f"Waiting for answers in {os.path.relpath(live_dir())}/ (ctrl-c stops)")
|
|
217
|
+
only = find_log(server_dir(), a.what) if a.what else None
|
|
218
|
+
return follow(Follower(server_dir(), a.lines, only),
|
|
219
|
+
f"Waiting for {'that model' if only else 'a server'} to log in "
|
|
220
|
+
f"{os.path.relpath(server_dir())}/ (ctrl-c stops)")
|
|
@@ -39,6 +39,8 @@ from textual.widgets.option_list import Option
|
|
|
39
39
|
from textual.widgets.selection_list import Selection
|
|
40
40
|
|
|
41
41
|
import yaml
|
|
42
|
+
from rich.cells import cell_len
|
|
43
|
+
from rich.text import Text
|
|
42
44
|
|
|
43
45
|
from . import compare
|
|
44
46
|
from . import engine
|
|
@@ -181,6 +183,79 @@ def fmt_secs(secs):
|
|
|
181
183
|
return f"{secs // 60}m{secs % 60:02d}s" if secs >= 60 else f"{secs}s"
|
|
182
184
|
|
|
183
185
|
|
|
186
|
+
# -- sorting Results tables by a click on a column name
|
|
187
|
+
SORT_ARROWS = (" ▲", " ▼")
|
|
188
|
+
SORT_RANKS = {"EASY": 1, "MEDIUM": 2, "HARD": 3, "FAIL": 1, "INCONCLUSIVE": 2, "PASS": 3,
|
|
189
|
+
"DOESN'T FIT": float("inf")}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SortCell(Text):
|
|
193
|
+
"""A table cell that sorts by its `sort_value` instead of what it shows."""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def sort_cell(markup, value):
|
|
197
|
+
t = Text.from_markup(markup)
|
|
198
|
+
cell = SortCell(t.plain, spans=t.spans)
|
|
199
|
+
cell.sort_value = value
|
|
200
|
+
return cell
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _sort_number(tok):
|
|
204
|
+
"""1,234 · 3.1k · 2.4× · 12/20 · 4m10s · 1h02m · 12.5s as a number, else None."""
|
|
205
|
+
tok = tok.rstrip("★×%").replace(",", "")
|
|
206
|
+
if m := re.fullmatch(r"(\d+)/(\d+)", tok): # answers right: the share, then the count
|
|
207
|
+
a, b = int(m[1]), int(m[2])
|
|
208
|
+
return (a / b if b else 0) + a * 1e-9
|
|
209
|
+
if (m := re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?", tok)) and any(m.groups()):
|
|
210
|
+
h, mi, s = m.groups()
|
|
211
|
+
return int(h or 0) * 3600 + int(mi or 0) * 60 + float(s or 0)
|
|
212
|
+
if m := re.fullmatch(r"(-?\d+(?:\.\d+)?)(k?)", tok):
|
|
213
|
+
return float(m[1]) * (1000 if m[2] else 1)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def sort_value(cell):
|
|
218
|
+
"""What a Results cell sorts by: the number it shows, a rank for levels and verdicts, else its
|
|
219
|
+
text; None (always last) for an empty cell."""
|
|
220
|
+
if isinstance(cell, SortCell):
|
|
221
|
+
return cell.sort_value
|
|
222
|
+
plain = (cell.plain if isinstance(cell, Text) else Text.from_markup(str(cell)).plain).strip()
|
|
223
|
+
if plain.lower() in ("", "-", "no data", "unrated"):
|
|
224
|
+
return None
|
|
225
|
+
if plain.upper() in SORT_RANKS:
|
|
226
|
+
return SORT_RANKS[plain.upper()]
|
|
227
|
+
first = re.sub(r"^(OK|TOO SLOW) ", "", plain).split()[0] # latency: by its p90 seconds
|
|
228
|
+
n = _sort_number(first)
|
|
229
|
+
return plain.casefold() if n is None else n
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class _Desc:
|
|
233
|
+
"""Reverses the order of what it wraps (so empty cells stay last when sorting descending)."""
|
|
234
|
+
__slots__ = ("v",)
|
|
235
|
+
|
|
236
|
+
def __init__(self, v):
|
|
237
|
+
self.v = v
|
|
238
|
+
|
|
239
|
+
def __eq__(self, other):
|
|
240
|
+
return self.v == other.v
|
|
241
|
+
|
|
242
|
+
def __lt__(self, other):
|
|
243
|
+
return other.v < self.v
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def sort_key(cell, reverse=False):
|
|
247
|
+
v = sort_value(cell)
|
|
248
|
+
if v is None:
|
|
249
|
+
return (1,)
|
|
250
|
+
return (0, isinstance(v, str), _Desc(v) if reverse else v)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def column_name(label):
|
|
254
|
+
"""A column's label without the sort arrow."""
|
|
255
|
+
text = label.plain if isinstance(label, Text) else str(label)
|
|
256
|
+
return next((text[:-len(a)] for a in SORT_ARROWS if text.endswith(a)), text)
|
|
257
|
+
|
|
258
|
+
|
|
184
259
|
class StreamView(TextArea):
|
|
185
260
|
"""Read-only, word-wrapped text that grows as tokens stream in. Scroll it with the mouse; it follows
|
|
186
261
|
new text only while scrolled to the bottom."""
|
|
@@ -340,6 +415,8 @@ with the model's reasoning and answer as they stream, then recent results with t
|
|
|
340
415
|
Per question every question, model by model (filters: pack, level, Models disagree)
|
|
341
416
|
Failures every wrong answer with the grader's reason; enter opens it in full
|
|
342
417
|
|
|
418
|
+
Click a column name to sort a table by it; click it again to reverse. Empty cells stay last.
|
|
419
|
+
|
|
343
420
|
More in the terminal: tuieval compare (--pairwise: is a difference real), tuieval history,
|
|
344
421
|
tuieval items (test quality).
|
|
345
422
|
|
|
@@ -2408,7 +2485,7 @@ class ResultsScreen(Screen):
|
|
|
2408
2485
|
yield DataTable(id="pq-summary", zebra_stripes=True, cursor_type="none")
|
|
2409
2486
|
yield Static(id="pq-legend")
|
|
2410
2487
|
yield DataTable(id="per-question", zebra_stripes=True, cursor_type="cell")
|
|
2411
|
-
with TabPane("Failures"):
|
|
2488
|
+
with TabPane("Failures", id="tab-failures"):
|
|
2412
2489
|
yield Static("[dim]Enter on a row shows the whole answer, its reasoning and what was checked.[/dim]")
|
|
2413
2490
|
yield DataTable(id="failures", zebra_stripes=True, cursor_type="row")
|
|
2414
2491
|
yield Footer()
|
|
@@ -2417,6 +2494,8 @@ class ResultsScreen(Screen):
|
|
|
2417
2494
|
self.rows, self.infos, self.partial_rows, self.superseded = [], [], [], set()
|
|
2418
2495
|
if not hasattr(self.app, "pq_models"):
|
|
2419
2496
|
self.app.pq_models = set()
|
|
2497
|
+
if not hasattr(self.app, "result_sorts"):
|
|
2498
|
+
self.app.result_sorts = {} # table id -> (column name, descending), kept for this session
|
|
2420
2499
|
self.fill()
|
|
2421
2500
|
|
|
2422
2501
|
def on_screen_resume(self):
|
|
@@ -2488,15 +2567,17 @@ class ResultsScreen(Screen):
|
|
|
2488
2567
|
f.add_columns("Model", "Level", "Test", "Reason")
|
|
2489
2568
|
# compare.failures lists the failed rows in order, so they line up with fail_rows
|
|
2490
2569
|
self.fail_rows = [r for r in rows if not r["ok"]]
|
|
2491
|
-
for model, test, reason, level in compare.failures(rows):
|
|
2492
|
-
f.add_row(model, diff_badge(level), test, rich_escape(reason[:130]))
|
|
2570
|
+
for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
|
|
2571
|
+
f.add_row(model, diff_badge(level), test, rich_escape(reason[:130]), key=str(i))
|
|
2493
2572
|
self.fill_per_question()
|
|
2494
2573
|
self.fill_pta()
|
|
2495
2574
|
self.fill_readiness(results_dir)
|
|
2575
|
+
self.sort_tables()
|
|
2496
2576
|
|
|
2497
2577
|
@on(DataTable.RowSelected, "#failures")
|
|
2498
2578
|
def failure_selected(self, event):
|
|
2499
|
-
|
|
2579
|
+
# in the order shown, so [ and ] step through them as sorted
|
|
2580
|
+
entries = [answer_entry(self.fail_rows[int(r.key.value)]) for r in event.data_table.ordered_rows]
|
|
2500
2581
|
i = event.cursor_row
|
|
2501
2582
|
if not entries[i]:
|
|
2502
2583
|
self.notify("This result is in the old format and has no stored answer.", severity="warning")
|
|
@@ -2507,7 +2588,6 @@ class ResultsScreen(Screen):
|
|
|
2507
2588
|
# -- per question: tokens and time for every model on the same question
|
|
2508
2589
|
@on(Select.Changed, "#pq-pack")
|
|
2509
2590
|
@on(Select.Changed, "#pq-level")
|
|
2510
|
-
@on(Select.Changed, "#pq-sort")
|
|
2511
2591
|
@on(Checkbox.Changed, "#pq-partial")
|
|
2512
2592
|
@on(Checkbox.Changed, "#pq-shared")
|
|
2513
2593
|
@on(Checkbox.Changed, "#pq-disagree")
|
|
@@ -2515,6 +2595,45 @@ class ResultsScreen(Screen):
|
|
|
2515
2595
|
if getattr(self, "rows", None) is not None:
|
|
2516
2596
|
self.fill_per_question()
|
|
2517
2597
|
|
|
2598
|
+
@on(Select.Changed, "#pq-sort")
|
|
2599
|
+
def pq_sort_changed(self):
|
|
2600
|
+
"""The Sort dropdown takes over from a column sorted by a click."""
|
|
2601
|
+
self.app.result_sorts.pop("per-question", None)
|
|
2602
|
+
self.per_question_changed()
|
|
2603
|
+
|
|
2604
|
+
@on(DataTable.HeaderSelected)
|
|
2605
|
+
def header_selected(self, event):
|
|
2606
|
+
"""A click on a column name sorts by it; another click on it reverses the order."""
|
|
2607
|
+
t, name = event.data_table, column_name(event.label)
|
|
2608
|
+
prev = self.app.result_sorts.get(t.id)
|
|
2609
|
+
self.app.result_sorts[t.id] = (name, not prev[1] if prev and prev[0] == name else False)
|
|
2610
|
+
self.sort_table(t)
|
|
2611
|
+
|
|
2612
|
+
def sort_tables(self):
|
|
2613
|
+
"""After a table is rebuilt: sort it again by the column picked (if that column is still there)."""
|
|
2614
|
+
for t in self.query(DataTable):
|
|
2615
|
+
self.sort_table(t)
|
|
2616
|
+
|
|
2617
|
+
def sort_table(self, t):
|
|
2618
|
+
name, desc = self.app.result_sorts.get(t.id, (None, False))
|
|
2619
|
+
found = None
|
|
2620
|
+
for col in t.ordered_columns:
|
|
2621
|
+
base = column_name(col.label)
|
|
2622
|
+
label = base + SORT_ARROWS[desc] if base == name else base
|
|
2623
|
+
if col.label.plain != label:
|
|
2624
|
+
col.label = Text(label)
|
|
2625
|
+
w = cell_len(label) # room for the arrow (a column keeps the width it was added with)
|
|
2626
|
+
col.content_width = max(col.content_width, w)
|
|
2627
|
+
if not col.auto_width:
|
|
2628
|
+
col.width = max(col.width, w)
|
|
2629
|
+
t._require_update_dimensions = True
|
|
2630
|
+
if base == name:
|
|
2631
|
+
found = col
|
|
2632
|
+
if found is not None:
|
|
2633
|
+
t.sort(found.key, key=lambda cell: sort_key(cell, desc))
|
|
2634
|
+
else:
|
|
2635
|
+
t.refresh()
|
|
2636
|
+
|
|
2518
2637
|
@on(Button.Pressed, "#pq-models")
|
|
2519
2638
|
@on(Button.Pressed, "#pta-models")
|
|
2520
2639
|
def pick_models(self):
|
|
@@ -2579,6 +2698,7 @@ class ResultsScreen(Screen):
|
|
|
2579
2698
|
for i, h in enumerate(header): # sized to the contents: added before the rows, a column keeps its header's width
|
|
2580
2699
|
t.add_column(h, width=max(len(h), *(len(r[i]) for r in table)) if table else None)
|
|
2581
2700
|
t.add_rows(table)
|
|
2701
|
+
self.sort_table(t)
|
|
2582
2702
|
|
|
2583
2703
|
def fill_per_question(self):
|
|
2584
2704
|
# a pack being rerun under new questions or settings: its old result file is on its way out,
|
|
@@ -2641,18 +2761,21 @@ class ResultsScreen(Screen):
|
|
|
2641
2761
|
best = min((c["tokens"] for c in t["cells"].values() if c["tokens"] and c["passed"]), default=None)
|
|
2642
2762
|
for m in models:
|
|
2643
2763
|
c = t["cells"].get(m)
|
|
2644
|
-
|
|
2764
|
+
# a model's column sorts by its tokens
|
|
2765
|
+
cells.append(sort_cell(self.pq_cell(c, best, here), c["tokens"] or None) if c else "[dim]-[/dim]")
|
|
2645
2766
|
spread = t["spread"]["tokens"]
|
|
2646
2767
|
sp = f"[yellow]{spread:.1f}×[/yellow]" if spread >= 3 else (f"{spread:.1f}×" if len(t["cells"]) > 1 else "")
|
|
2647
2768
|
desc = t["cells"][next(iter(t["cells"]))]["rows"][0].get("raw", {}).get("description") or t["name"]
|
|
2648
2769
|
table.add_row(rich_escape(desc[:48]), t["suite"], diff_badge(t["difficulty"]), sp, *cells, key=t["test"])
|
|
2649
2770
|
self.pq_tests[t["test"]] = t
|
|
2771
|
+
self.sort_table(table)
|
|
2772
|
+
self.sort_table(self.query_one("#pq-summary", DataTable))
|
|
2650
2773
|
self.query_one("#pq-legend", Static).update(
|
|
2651
2774
|
f"[dim]{len(tests)} questions. Each cell: result (passes/answers if repeated), median tokens, median seconds. "
|
|
2652
2775
|
"[green]bold[/green] = fewest tokens among passing answers · [red]cut[/red] = hit max_tokens · "
|
|
2653
2776
|
"◐ = pack unfinished (not in scores) · dim seconds = hosted, or measured on another machine; tokens compare "
|
|
2654
2777
|
"across machines, seconds don't. ×tokens = most / fewest tokens across models. Enter on a cell opens the "
|
|
2655
|
-
"answers.[/dim]" + ("\n[yellow]Left out, being rerun with other questions or settings: " + ", ".join(
|
|
2778
|
+
"answers. Click a column name to sort by it (a model's column: by its tokens).[/dim]" + ("\n[yellow]Left out, being rerun with other questions or settings: " + ", ".join(
|
|
2656
2779
|
f"{os.path.basename(os.path.dirname(p))} · {os.path.basename(p)[:-5]}" for p in sorted(self.superseded))
|
|
2657
2780
|
+ ". Only the new run's answers (◐) are shown for these.[/yellow]" if self.superseded else ""))
|
|
2658
2781
|
|
|
@@ -803,6 +803,18 @@ class PTAIndex(unittest.TestCase):
|
|
|
803
803
|
picked = pta.index(rows, ["a", "few"]) # picked by hand: compared anyway
|
|
804
804
|
self.assertEqual((picked["questions"], picked["left_out"]), (1, []))
|
|
805
805
|
|
|
806
|
+
def test_results_sort_values(self):
|
|
807
|
+
from tuieval.tui import diff_badge, sort_cell, sort_key, sort_value
|
|
808
|
+
got = [sort_value(c) for c in ("1,234 ★", "3.1k", "2.4×", "4m10s", "1h02m", "12.5s", diff_badge("hard"),
|
|
809
|
+
"[bold green]PASS[/]", "[green]OK 12s[/] [dim]measured[/dim]",
|
|
810
|
+
"[bold red]doesn't fit[/]", "Top 10 holdings")]
|
|
811
|
+
self.assertEqual(got, [1234, 3100, 2.4, 250, 3720, 12.5, 3, 3, 12, float("inf"), "top 10 holdings"])
|
|
812
|
+
self.assertEqual([sort_value(c) for c in ("-", "[dim]-[/dim]", "[dim]no data[/]", "")], [None] * 4)
|
|
813
|
+
self.assertEqual(sort_value(sort_cell("[green]✓[/green] 3.1k 12s", 3100)), 3100)
|
|
814
|
+
cells = ["3/4", "-", "10/20", "4/4", "abc"] # numbers, then text; empty cells last both ways
|
|
815
|
+
self.assertEqual(sorted(cells, key=sort_key), ["10/20", "3/4", "4/4", "abc", "-"])
|
|
816
|
+
self.assertEqual(sorted(cells, key=lambda c: sort_key(c, True)), ["4/4", "3/4", "10/20", "abc", "-"])
|
|
817
|
+
|
|
806
818
|
def test_cli_report_and_tui(self):
|
|
807
819
|
with tempfile.TemporaryDirectory() as tmp:
|
|
808
820
|
ws = os.path.join(tmp, "ws")
|
|
@@ -857,8 +869,37 @@ class PTAIndex(unittest.TestCase):
|
|
|
857
869
|
app.screen.fill_per_question()
|
|
858
870
|
await pilot.pause(0.3)
|
|
859
871
|
alone = pq.row_count
|
|
872
|
+
tab = app.screen.query_one(TabbedContent).active
|
|
873
|
+
|
|
874
|
+
async def click_header(t, name): # a real click on the column name
|
|
875
|
+
i = next(i for i, c in enumerate(t.ordered_columns) if c.label.plain.startswith(name))
|
|
876
|
+
x = sum(c.get_render_width(t) for c in t.ordered_columns[:i]) + 1
|
|
877
|
+
await pilot.click(t, offset=(x, 0))
|
|
878
|
+
await pilot.pause(0.2)
|
|
879
|
+
return [str(t.get_row_at(r)[0]) for r in range(t.row_count)], \
|
|
880
|
+
[c.label.plain for c in t.ordered_columns]
|
|
881
|
+
sorts = {}
|
|
882
|
+
pta_t = app.screen.query_one("#pta", DataTable)
|
|
883
|
+
sorts["up"], _ = await click_header(pta_t, "answers right")
|
|
884
|
+
sorts["down"], sorts["labels"] = await click_header(pta_t, "answers right")
|
|
885
|
+
app.pq_models = set()
|
|
886
|
+
app.screen.fill_pta() # rebuilt: still sorted
|
|
887
|
+
await pilot.pause(0.2)
|
|
888
|
+
sorts["kept"] = [str(pta_t.get_row_at(r)[0]) for r in range(pta_t.row_count)]
|
|
889
|
+
app.screen.query_one(TabbedContent).active = "tab-failures"
|
|
890
|
+
await pilot.pause(0.3)
|
|
891
|
+
f = app.screen.query_one("#failures", DataTable)
|
|
892
|
+
await click_header(f, "Test")
|
|
893
|
+
sorts["tests"], _ = await click_header(f, "Test")
|
|
894
|
+
sorts["tests"] = [str(f.get_row_at(r)[2]) for r in range(f.row_count)]
|
|
895
|
+
f.focus()
|
|
896
|
+
f.move_cursor(row=0)
|
|
897
|
+
await pilot.press("enter") # opens the row shown, not the first failure
|
|
898
|
+
await pilot.pause(0.3)
|
|
899
|
+
s = app.screen
|
|
900
|
+
sorts["opened"] = s.entries[s.index]["record"]["test"]
|
|
860
901
|
print(json.dumps({"rows": rows, "bars": "P parsimony" in tri, "disagree": [both, alone],
|
|
861
|
-
"tab":
|
|
902
|
+
"tab": tab, "sorts": sorts}))
|
|
862
903
|
asyncio.run(go())
|
|
863
904
|
""")
|
|
864
905
|
p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
|
|
@@ -869,6 +910,10 @@ class PTAIndex(unittest.TestCase):
|
|
|
869
910
|
self.assertEqual(got["disagree"], [4, 0])
|
|
870
911
|
self.assertEqual(got["tab"], "tab-pta") # Results opens on the PTA index
|
|
871
912
|
self.assertEqual([(r[0], r[3]) for r in got["rows"]][0], ("good", "4/4"))
|
|
913
|
+
s = got["sorts"] # a click on a column name sorts by it, a second click reverses
|
|
914
|
+
self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
|
|
915
|
+
self.assertIn("answers right ▼", s["labels"])
|
|
916
|
+
self.assertEqual((s["tests"][0], s["opened"]), ("apps: d", "d"))
|
|
872
917
|
|
|
873
918
|
|
|
874
919
|
class Unavailable(unittest.TestCase):
|
|
@@ -1660,5 +1705,88 @@ class Remove(unittest.TestCase):
|
|
|
1660
1705
|
self.assertTrue(os.path.exists(os.path.join(ws, "removed", kept, rel)), rel)
|
|
1661
1706
|
|
|
1662
1707
|
|
|
1708
|
+
|
|
1709
|
+
class Logs(unittest.TestCase):
|
|
1710
|
+
"""tuieval logs: follows whichever model's server log is being written, without naming it."""
|
|
1711
|
+
|
|
1712
|
+
def test_follows_the_log_being_written(self):
|
|
1713
|
+
from tuieval import logs
|
|
1714
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1715
|
+
a, b = os.path.join(d, "model-a.log"), os.path.join(d, "model-b.log")
|
|
1716
|
+
write(a, "a old\n")
|
|
1717
|
+
write(b, "".join(f"b {i}\n" for i in range(50)))
|
|
1718
|
+
os.utime(a, (time.time() - 60,) * 2)
|
|
1719
|
+
f = logs.Follower(d, lines=3)
|
|
1720
|
+
self.assertEqual(f.poll(), "── model-b ──\nb 47\nb 48\nb 49\n") # the newest, its last lines
|
|
1721
|
+
self.assertEqual(f.poll(), "")
|
|
1722
|
+
with open(a, "a") as fh:
|
|
1723
|
+
fh.write("a new\n") # the run moved on to model a
|
|
1724
|
+
self.assertEqual(f.poll(), "── model-a ──\na new\n")
|
|
1725
|
+
with open(b, "a") as fh:
|
|
1726
|
+
fh.write("half a li") # a line still being written
|
|
1727
|
+
self.assertEqual(f.poll(), "")
|
|
1728
|
+
self.assertEqual(f.poll(), "── model-b ──\nhalf a li") # unchanged since: shown anyway
|
|
1729
|
+
write(os.path.join(d, "model-c.log"), "c starts\n") # a model that never ran before
|
|
1730
|
+
self.assertEqual(f.poll(), "── model-c ──\nc starts\n")
|
|
1731
|
+
write(a, "fresh\n") # replaced: read from the start
|
|
1732
|
+
self.assertEqual(f.poll(), "── model-a ──\nfresh\n")
|
|
1733
|
+
only = logs.Follower(d, lines=1, only=b)
|
|
1734
|
+
self.assertEqual(only.poll(), "── model-b ──\nb 49\n") # complete lines first
|
|
1735
|
+
self.assertEqual((only.poll(), only.poll()), ("", "half a li")) # held one poll, as above
|
|
1736
|
+
with open(a, "a") as fh:
|
|
1737
|
+
fh.write("ignored\n")
|
|
1738
|
+
self.assertEqual(only.poll(), "")
|
|
1739
|
+
|
|
1740
|
+
def test_live_answers(self):
|
|
1741
|
+
from tuieval import logs
|
|
1742
|
+
with tempfile.TemporaryDirectory() as d:
|
|
1743
|
+
def answer(name, done=True, age=0):
|
|
1744
|
+
path = os.path.join(d, name + ".txt")
|
|
1745
|
+
write(path, "MODEL: m\nPROMPT: the question\n\n=== REASONING ===\nthink\n\n=== ANSWER ===\n42\n"
|
|
1746
|
+
+ ("\n=== 10 tokens, 1.0s, finish=stop ===\n" if done else ""))
|
|
1747
|
+
os.utime(path, (time.time() - age,) * 2)
|
|
1748
|
+
answer("20260101_100000_m_pack_old", age=20)
|
|
1749
|
+
answer("20260101_100100_m_pack_last", age=10)
|
|
1750
|
+
f = logs.LiveFollower(d)
|
|
1751
|
+
first = f.poll()
|
|
1752
|
+
self.assertIn("── 20260101_100100_m_pack_last ──\n=== REASONING ===", first) # the latest one only
|
|
1753
|
+
self.assertNotIn("_old", first)
|
|
1754
|
+
self.assertNotIn("PROMPT", first)
|
|
1755
|
+
answer("20260101_100200_m_pack_new", done=False) # still being written
|
|
1756
|
+
self.assertEqual(f.poll(), "")
|
|
1757
|
+
answer("20260101_100200_m_pack_new")
|
|
1758
|
+
self.assertIn("=== ANSWER ===\n42", f.poll())
|
|
1759
|
+
|
|
1760
|
+
def test_cli(self):
|
|
1761
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1762
|
+
ws = os.path.join(tmp, "ws")
|
|
1763
|
+
tuieval(tmp, "init", ws)
|
|
1764
|
+
self.assertIn("No server logs yet", tuieval(ws, "logs", "--list").stdout)
|
|
1765
|
+
d = os.path.join(ws, "logs", "server")
|
|
1766
|
+
os.makedirs(d, exist_ok=True)
|
|
1767
|
+
for i, name in enumerate(("coder-q4", "coder-q5", "chat")):
|
|
1768
|
+
write(os.path.join(d, name + ".log"), f"{name} line\n")
|
|
1769
|
+
os.utime(os.path.join(d, name + ".log"), (time.time() - 100 + i,) * 2)
|
|
1770
|
+
listed = tuieval(ws, "logs", "--list").stdout.splitlines()
|
|
1771
|
+
self.assertEqual([l.split()[0] for l in listed], ["chat", "coder-q5", "coder-q4"]) # newest first
|
|
1772
|
+
p = tuieval(ws, "logs", "coder", check=False)
|
|
1773
|
+
self.assertNotEqual(p.returncode, 0)
|
|
1774
|
+
self.assertIn("matches 2 logs", p.stderr)
|
|
1775
|
+
self.assertIn("No server log matches", tuieval(ws, "logs", "nothing", check=False).stderr)
|
|
1776
|
+
# following: the newest log, then whichever one grows; ctrl-c stops it cleanly
|
|
1777
|
+
proc = subprocess.Popen([sys.executable, "-m", "tuieval", "logs", "-n", "1"], stdout=subprocess.PIPE,
|
|
1778
|
+
text=True, env=dict(os.environ, TUIEVAL_HOME=ws))
|
|
1779
|
+
try:
|
|
1780
|
+
time.sleep(1.5)
|
|
1781
|
+
with open(os.path.join(d, "coder-q4.log"), "a") as fh:
|
|
1782
|
+
fh.write("q4 server started\n")
|
|
1783
|
+
time.sleep(1.5)
|
|
1784
|
+
finally:
|
|
1785
|
+
proc.send_signal(__import__("signal").SIGINT)
|
|
1786
|
+
out, _ = proc.communicate(timeout=20)
|
|
1787
|
+
self.assertEqual(proc.returncode, 0)
|
|
1788
|
+
self.assertEqual(out, "── chat ──\nchat line\n── coder-q4 ──\nq4 server started\n")
|
|
1789
|
+
|
|
1790
|
+
|
|
1663
1791
|
if __name__ == "__main__":
|
|
1664
1792
|
unittest.main()
|
|
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
|
|
File without changes
|
|
File without changes
|
{tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev66 → tuieval-0.2.0.dev70}/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
|
|
File without changes
|