tuieval 0.2.0.dev133__tar.gz → 0.2.0.dev137__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.dev133 → tuieval-0.2.0.dev137}/CHANGELOG.md +2 -1
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/PKG-INFO +1 -1
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/models.md +6 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/builtin.toml +2 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/client.py +36 -1
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/compare.py +3 -2
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/engine.py +7 -4
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/graders/__init__.py +4 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/insights.py +1 -1
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/tui.py +26 -6
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/tests/mock_server.py +6 -2
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/tests/test_tuieval.py +91 -8
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/.gitignore +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/LICENSE +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/README.md +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/agent-packs.md +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/agents.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/cli.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/dates.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/logs.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/pta.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/run_evals.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/yamlout.py +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
-
|
|
5
|
+
- Answers stuck in a loop (one short unit repeated over 2000 chars, in the reasoning or the answer) are stopped as they stream and failed as `LOOP`, instead of running to max_tokens. `[defaults] loop_stop_chars` sets the length; 0 turns it off.
|
|
6
|
+
- Repeats: the PTA index, Per question, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
|
|
6
7
|
- Partial credit: the PTA index shows each model's score (e.g. 5 of 6 checks passed is 83%) beside accuracy, and can be ordered by it (By score, `tuieval pta --sort score`). Why models fail splits failures into near miss, partly right and wrong; Failures shows each one's kind and checks and filters by kind; Per question cells show `✗ 5/6`. Verdicts are unchanged.
|
|
7
8
|
- Results: picking a pack (PTA index or Per question) compares the models that answered all of it; the Models button can change that, and All packs goes back to the earlier pick.
|
|
8
9
|
- Fix: a request that runs out of time says `time limit: timed out after N s`, not `connection error`, and `tuieval compare` counts it against the model like the run does (it was left out as an error).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev137
|
|
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
|
|
@@ -198,6 +198,10 @@ On Apple Silicon Macs, once the system's GPU allocations pass about half of RAM,
|
|
|
198
198
|
| `thinking_param` | `reasoning` to send `[sampling] enable_thinking` as OpenRouter's `reasoning.enabled` |
|
|
199
199
|
| `headers` | extra HTTP headers |
|
|
200
200
|
|
|
201
|
+
## Answers stuck in a loop
|
|
202
|
+
|
|
203
|
+
A model sometimes gets stuck repeating one short piece of text (a closing tag, "wait, ", a line) until it runs out of `max_tokens`, which can take many minutes and fails anyway. tuieval watches the reasoning and the answer as they stream: once either ends in one unit of up to 64 characters repeated back to back over the last `loop_stop_chars` characters (2000 by default; units without a letter or digit, like `-----`, don't count), it ends the request there. The answer fails with `LOOP: the answer kept repeating '…'`, its finish is `loop` (the answer view says so), Results → Insights counts it as cut off and `tuieval compare` in **rep**. Set `loop_stop_chars = 0` in `[defaults]` to let loops run to the end, as before.
|
|
204
|
+
|
|
201
205
|
## Built-in servers and defaults
|
|
202
206
|
|
|
203
207
|
A workspace's `models.toml` only needs what differs from these. `[defaults]` fill in key by key. `[sampling]` applies only when `models.toml` has no `[sampling]` section at all, because sampling is part of every result's fingerprint. A server you define replaces the built-in one with the same name; to change one setting, copy the whole block into `models.toml` and edit it. `tuieval add` adds a `[servers.<name>]` block with just a `url` when it finds a model on a server that's already running.
|
|
@@ -210,6 +214,8 @@ A workspace's `models.toml` only needs what differs from these. `[defaults]` fil
|
|
|
210
214
|
repeat = 3
|
|
211
215
|
request_timeout_ms = 1800000 # per request; long thinking runs need it
|
|
212
216
|
stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
|
|
217
|
+
loop_stop_chars = 2000 # an answer (or reasoning) ending in one short unit repeated over this many chars is
|
|
218
|
+
# stuck in a loop: stopped there and failed as LOOP, not left to run to max_tokens (0: off)
|
|
213
219
|
over_budget = "flag" # answers past the max_tokens sent (a provider that didn't stop them): "flag" or "fail"
|
|
214
220
|
# reference = "<label>" # the model Results → Insights compares against by default
|
|
215
221
|
# agent = "<name>" # the [agents.<name>] that runs agent packs (see docs/agent-packs.md)
|
|
@@ -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.dev137'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev137')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
repeat = 3
|
|
7
7
|
request_timeout_ms = 1800000 # per request; long thinking runs need it
|
|
8
8
|
stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
|
|
9
|
+
loop_stop_chars = 2000 # an answer (or reasoning) ending in one short unit repeated over this many chars is
|
|
10
|
+
# stuck in a loop: stopped there and failed as LOOP, not left to run to max_tokens (0: off)
|
|
9
11
|
over_budget = "flag" # answers past the max_tokens sent (a provider that didn't stop them): "flag" or "fail"
|
|
10
12
|
ready_timeout_s = 1200 # how long a model may take to load before giving up
|
|
11
13
|
model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
|
|
@@ -9,9 +9,12 @@ answer together with the numbers that matter for choosing a local model:
|
|
|
9
9
|
prompt_tps prompt processing speed, tokens/s (llama.cpp reports it)
|
|
10
10
|
prompt_tokens, completion_tokens, and — when the split can be known — reasoning_tokens and
|
|
11
11
|
answer_tokens (tokens_estimated=True means the split was estimated from text length)
|
|
12
|
+
loop set when the request was ended early because the model got stuck repeating itself
|
|
13
|
+
({"stream": "reasoning" or "answer", "unit": the repeated text}; finish is "loop")
|
|
12
14
|
"""
|
|
13
15
|
import http.client
|
|
14
16
|
import json
|
|
17
|
+
import re
|
|
15
18
|
import socket
|
|
16
19
|
import time
|
|
17
20
|
import urllib.parse
|
|
@@ -119,17 +122,37 @@ def count_tokens(base_url, text):
|
|
|
119
122
|
return None
|
|
120
123
|
|
|
121
124
|
|
|
125
|
+
LOOP_MAX_UNIT = 64 # longest repeated unit looked for (a short line, a closing tag, a phrase)
|
|
126
|
+
LOOP_EVERY = 256 # chars of new text between checks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def loop_unit(text, min_chars):
|
|
130
|
+
"""The text the end of `text` repeats, exactly and back to back, over its last min_chars or more (a unit of
|
|
131
|
+
at most LOOP_MAX_UNIT chars with a letter or digit in it, so rulers like '-----' don't count), else None."""
|
|
132
|
+
if not min_chars or len(text) < min_chars:
|
|
133
|
+
return None
|
|
134
|
+
tail = text[-min_chars:]
|
|
135
|
+
for n in range(1, LOOP_MAX_UNIT + 1):
|
|
136
|
+
if tail[n:] == tail[:-n]: # the tail has period n: one unit over and over
|
|
137
|
+
unit = tail[-n:]
|
|
138
|
+
return unit if re.search(r"[A-Za-z0-9]", unit) else None
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
122
142
|
def _stalled(stall_s):
|
|
123
143
|
return f"server error: stalled, no tokens for {stall_s:.0f} s after it started answering"
|
|
124
144
|
|
|
125
145
|
|
|
126
|
-
def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, headers=None, stall_s=None
|
|
146
|
+
def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, headers=None, stall_s=None,
|
|
147
|
+
loop_chars=None):
|
|
127
148
|
"""POST {base_url}/v1/chat/completions with streaming. Returns a dict (see module docstring).
|
|
128
149
|
|
|
129
150
|
on_delta(kind, text) is called for "reasoning" and "answer" text as it arrives.
|
|
130
151
|
timeout bounds the whole request, keep-alive comments (OpenRouter's ": OPENROUTER PROCESSING")
|
|
131
152
|
included; running out counts against the model, like any slow answer. stall_s: once tokens have
|
|
132
153
|
started, this long without one is the server stalling (a server error, never the model's answer).
|
|
154
|
+
loop_chars: when the reasoning or the answer ends in one short unit repeated over this many chars, the
|
|
155
|
+
model is stuck in a loop that would run to max_tokens: the request ends there (finish "loop").
|
|
133
156
|
"""
|
|
134
157
|
stream = stream or Stream()
|
|
135
158
|
body = dict(body, stream=True, stream_options={"include_usage": True})
|
|
@@ -138,6 +161,7 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
|
|
|
138
161
|
out = {"answer": "", "reasoning": "", "tool_calls": [], "finish": None, "usage": {}, "timings": {},
|
|
139
162
|
"ttft_s": None, "total_s": None, "error": None}
|
|
140
163
|
reasoning, answer, tools = [], [], {}
|
|
164
|
+
tails, unchecked = {"reasoning": "", "answer": ""}, {"reasoning": 0, "answer": 0} # for loop_chars
|
|
141
165
|
done = False # [DONE] seen
|
|
142
166
|
start = last = time.time()
|
|
143
167
|
try:
|
|
@@ -195,6 +219,17 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
|
|
|
195
219
|
if text:
|
|
196
220
|
answer.append(text)
|
|
197
221
|
on_delta and on_delta("answer", text)
|
|
222
|
+
for kind, piece in (("reasoning", think), ("answer", text)):
|
|
223
|
+
if loop_chars and piece:
|
|
224
|
+
tails[kind] = (tails[kind] + piece)[-loop_chars:]
|
|
225
|
+
unchecked[kind] += len(piece)
|
|
226
|
+
if unchecked[kind] >= LOOP_EVERY:
|
|
227
|
+
unchecked[kind] = 0
|
|
228
|
+
unit = loop_unit(tails[kind], loop_chars)
|
|
229
|
+
if unit:
|
|
230
|
+
out["finish"], out["loop"] = "loop", {"stream": kind, "unit": unit}
|
|
231
|
+
if out.get("loop"):
|
|
232
|
+
break
|
|
198
233
|
if not out["error"] and not done and out["finish"] is None:
|
|
199
234
|
# the stream just ended: a server that crashed or dropped the connection, not the model's answer
|
|
200
235
|
out["error"] = "connection error: the stream ended before the server finished (no finish reason)"
|
|
@@ -52,6 +52,7 @@ def _load_native(data):
|
|
|
52
52
|
# a timeout is the model too slow (client.is_server_error), not an error; older results called it one
|
|
53
53
|
"error": r["reason"].startswith(("server returned", "connection error")) and "timed out" not in r["reason"],
|
|
54
54
|
"truncated": r.get("finish") == "length", "output": r.get("answer") or "",
|
|
55
|
+
"loop": r.get("finish") == "loop", # stopped live: it kept repeating itself (client.loop_unit)
|
|
55
56
|
# tokens past the max_tokens sent, which the provider didn't stop (client.mark_over_budget)
|
|
56
57
|
"over_budget": r.get("over_budget"),
|
|
57
58
|
"tokens": r.get("completion_tokens") or 0, "reasoning_tokens": r.get("reasoning_tokens"),
|
|
@@ -273,7 +274,7 @@ def scorecard(rows, rng=None):
|
|
|
273
274
|
line.append("-" if cell is None else str(cell))
|
|
274
275
|
line += [str(sum(r["error"] for r in mine)), str(sum(r["truncated"] for r in mine)),
|
|
275
276
|
*([str(sum(bool(r.get("over_budget")) for r in mine))] if over else []),
|
|
276
|
-
str(sum(repetitive(r["output"]) for r in mine))]
|
|
277
|
+
str(sum(bool(r.get("loop")) or repetitive(r["output"]) for r in mine))]
|
|
277
278
|
table.append(line)
|
|
278
279
|
return header, table
|
|
279
280
|
|
|
@@ -682,7 +683,7 @@ def main(argv):
|
|
|
682
683
|
print_table(*scorecard(rows, rng))
|
|
683
684
|
print("\nrepeats = answers per question (×1: one answer each, the roughest accuracy).")
|
|
684
685
|
print("mean score = average score, with partial credit; all repeats = tests passed on every repeat.")
|
|
685
|
-
print("trunc = hit max_tokens; rep = output ends in a degenerate repeated pattern.")
|
|
686
|
+
print("trunc = hit max_tokens; rep = output ends in a degenerate repeated pattern (or was stopped live as a loop).")
|
|
686
687
|
header, table = by_difficulty(rows)
|
|
687
688
|
if any(r.get("difficulty") in LEVELS for r in rows):
|
|
688
689
|
print("\nPass rate by difficulty:")
|
|
@@ -2400,6 +2400,7 @@ class Engine:
|
|
|
2400
2400
|
earlier_s=sum(s.get("wall_s") or 0 for s in job.earlier), sittings=len(job.earlier))
|
|
2401
2401
|
timeout = self.cfg["defaults"]["request_timeout_ms"] / 1000
|
|
2402
2402
|
stall_s = (self.cfg["defaults"].get("stall_timeout_ms") or 0) / 1000 or None
|
|
2403
|
+
loop_chars = int(self.cfg["defaults"].get("loop_stop_chars") or 0) or None
|
|
2403
2404
|
headers = self.request_headers(job.model)
|
|
2404
2405
|
# a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
|
|
2405
2406
|
tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
|
|
@@ -2429,7 +2430,7 @@ class Engine:
|
|
|
2429
2430
|
lambda kind, text: self.emit("delta", stream=kind, text=text,
|
|
2430
2431
|
label=job.label),
|
|
2431
2432
|
timeout=timeout, stream=self._stream, headers=headers,
|
|
2432
|
-
stall_s=stall_s)
|
|
2433
|
+
stall_s=stall_s, loop_chars=loop_chars)
|
|
2433
2434
|
except client.Cancelled:
|
|
2434
2435
|
self._check()
|
|
2435
2436
|
raise Cancelled()
|
|
@@ -2466,8 +2467,8 @@ class Engine:
|
|
|
2466
2467
|
if res["error"]:
|
|
2467
2468
|
g = {"pass": False, "score": 0.0, "reason": res["error"]}
|
|
2468
2469
|
else:
|
|
2469
|
-
g = graders.grade(test, res["answer"],
|
|
2470
|
-
|
|
2470
|
+
g = graders.grade(test, res["answer"], {"finish": res["finish"], "tool_calls": res["tool_calls"],
|
|
2471
|
+
"loop": res.get("loop")})
|
|
2471
2472
|
log_name = self._log_request(job, test, res, meta)
|
|
2472
2473
|
rec = {"test": test["id"], "description": test["description"], "category": test.get("category", ""),
|
|
2473
2474
|
"difficulty": test.get("difficulty", "unrated"),
|
|
@@ -2475,6 +2476,7 @@ class Engine:
|
|
|
2475
2476
|
"repeat": rep, "pass": g["pass"], "score": g["score"], "reason": g["reason"],
|
|
2476
2477
|
"severity": g.get("severity"), "answer": res["answer"], "tool_calls": res["tool_calls"],
|
|
2477
2478
|
"finish": res["finish"], "reasoning_chars": len(res["reasoning"]),
|
|
2479
|
+
**({"loop": res["loop"]} if res.get("loop") else {}),
|
|
2478
2480
|
"log": log_name, # logs/live/<log>: the reasoning, which the result doesn't keep
|
|
2479
2481
|
"answered_at": dates.now(),
|
|
2480
2482
|
**({"checks": g["checks"]} if "checks" in g else {}),
|
|
@@ -2598,7 +2600,8 @@ def regrade(path, packs_dir=None):
|
|
|
2598
2600
|
continue
|
|
2599
2601
|
if r["reason"].startswith(("server returned", "connection error")):
|
|
2600
2602
|
continue
|
|
2601
|
-
g = graders.grade(t, r["answer"], {"finish": r["finish"], "tool_calls": r.get("tool_calls", [])
|
|
2603
|
+
g = graders.grade(t, r["answer"], {"finish": r["finish"], "tool_calls": r.get("tool_calls", []),
|
|
2604
|
+
"loop": r.get("loop")})
|
|
2602
2605
|
r.update({"pass": g["pass"], "score": g["score"], "reason": g["reason"], "severity": g.get("severity"),
|
|
2603
2606
|
"critical_test": bool(t.get("critical_trial")), "group": t.get("group")})
|
|
2604
2607
|
r.pop("checks", None)
|
|
@@ -137,6 +137,10 @@ def grade(test, answer, meta=None):
|
|
|
137
137
|
return result(False, f"unknown grader {name!r}; known: {', '.join(sorted(REGISTRY))}")
|
|
138
138
|
if meta.get("finish") == "length":
|
|
139
139
|
return result(False, "TRUNCATED: hit max_tokens before finishing")
|
|
140
|
+
if meta.get("finish") == "loop":
|
|
141
|
+
loop = meta.get("loop") or {}
|
|
142
|
+
return result(False, f"LOOP: the {loop.get('stream', 'answer')} kept repeating "
|
|
143
|
+
f"{loop.get('unit', '')[:40]!r}; stopped early (it would have run to max_tokens)")
|
|
140
144
|
text = strip_thinking(answer)
|
|
141
145
|
if not text and not meta.get("tool_calls"):
|
|
142
146
|
return result(False, "Empty answer")
|
|
@@ -280,7 +280,7 @@ def kind(r):
|
|
|
280
280
|
return "right"
|
|
281
281
|
if r.get("severity") == "critical":
|
|
282
282
|
return "critical"
|
|
283
|
-
if r["truncated"] or r["reason"].startswith(("TRUNCATED", "OVER BUDGET", "time limit")):
|
|
283
|
+
if r["truncated"] or r["reason"].startswith(("TRUNCATED", "OVER BUDGET", "time limit", "LOOP")):
|
|
284
284
|
return "cut off"
|
|
285
285
|
if r["reason"].startswith(FORMAT):
|
|
286
286
|
return "no answer / format"
|
|
@@ -431,6 +431,15 @@ def trim_names(names, width):
|
|
|
431
431
|
return trimmed if len(set(trimmed)) == len(set(names)) else list(names)
|
|
432
432
|
|
|
433
433
|
|
|
434
|
+
def repeats_tag(counts):
|
|
435
|
+
"""A model's answers per question as markup, as the PTA index shows them: "×3", or "×1-3" when it varies;
|
|
436
|
+
yellow when it's ×1 (one answer decides each result, the roughest), else dim. "" for no answers."""
|
|
437
|
+
if not counts:
|
|
438
|
+
return ""
|
|
439
|
+
few, most = min(counts), max(counts)
|
|
440
|
+
return f"[{'yellow' if most == 1 else 'dim'}]×{few}{'' if few == most else f'-{most}'}[/]"
|
|
441
|
+
|
|
442
|
+
|
|
434
443
|
def fit_first_column(header, rows, room, floor=24):
|
|
435
444
|
"""rows with the first cell trimmed in the middle (mid_trim) so a table of these columns fits room
|
|
436
445
|
cells across, but never below floor cells. Cells are Rich markup; each column adds 2 cells of padding."""
|
|
@@ -624,7 +633,8 @@ class AnswerScreen(ModalScreen):
|
|
|
624
633
|
quant = f" [magenta]{rec['quantization']}[/magenta]" if rec.get("quantization") else ""
|
|
625
634
|
finish = rec.get("finish") or "?"
|
|
626
635
|
limit = f"this pack's max_tokens, {pack.max_tokens:,}" if pack and pack.max_tokens else "max_tokens"
|
|
627
|
-
finish = f"[bold red]{finish} (cut off at {limit})[/]" if finish == "length"
|
|
636
|
+
finish = (f"[bold red]{finish} (cut off at {limit})[/]" if finish == "length"
|
|
637
|
+
else f"[bold red]loop (stopped: it kept repeating itself)[/]" if finish == "loop" else finish)
|
|
628
638
|
toks = rec.get("completion_tokens")
|
|
629
639
|
split = ", ".join(f"{k} {rec[k + '_tokens']:,}" for k in ("reasoning", "answer")
|
|
630
640
|
if rec.get(k + "_tokens") is not None)
|
|
@@ -3135,7 +3145,10 @@ class ResultsScreen(Screen):
|
|
|
3135
3145
|
base = column_name(col.label)
|
|
3136
3146
|
label = base + SORT_ARROWS[desc] if base == name else base
|
|
3137
3147
|
if col.label.plain != label:
|
|
3138
|
-
col.label
|
|
3148
|
+
text = col.label.copy() # keeps its styling, e.g. a model's ×n
|
|
3149
|
+
if len(text.plain) > len(base): # right_crop(0) would empty it
|
|
3150
|
+
text.right_crop(len(text.plain) - len(base))
|
|
3151
|
+
col.label = text + SORT_ARROWS[desc] if base == name else text
|
|
3139
3152
|
w = cell_len(label) # room for the arrow (a column keeps the width it was added with)
|
|
3140
3153
|
col.content_width = max(col.content_width, w)
|
|
3141
3154
|
if not col.auto_width:
|
|
@@ -3403,7 +3416,9 @@ class ResultsScreen(Screen):
|
|
|
3403
3416
|
here = self.app.engine.machine().id
|
|
3404
3417
|
table = self.query_one("#per-question", DataTable)
|
|
3405
3418
|
table.clear(columns=True)
|
|
3406
|
-
table.add_columns(*self.PQ_FIXED, *
|
|
3419
|
+
table.add_columns(*self.PQ_FIXED, *(Text.from_markup(
|
|
3420
|
+
f"{rich_escape(m)} {repeats_tag([t['cells'][m]['n'] for t in tests if m in t['cells']])}".rstrip())
|
|
3421
|
+
for m in models))
|
|
3407
3422
|
self.pq_tests, self.pq_models = {}, models
|
|
3408
3423
|
for t in tests:
|
|
3409
3424
|
cells = []
|
|
@@ -3426,7 +3441,8 @@ class ResultsScreen(Screen):
|
|
|
3426
3441
|
"[green]bold[/green] = fewest tokens among passing answers · [red]cut[/red] = hit max_tokens · "
|
|
3427
3442
|
"[yellow]over[/yellow] = ran past max_tokens (the provider didn't stop it) · "
|
|
3428
3443
|
"◐ = pack unfinished (not in scores) · dim seconds = hosted, or measured on another machine; tokens compare "
|
|
3429
|
-
"across machines, seconds don't. ×tokens = most / fewest tokens across models.
|
|
3444
|
+
"across machines, seconds don't. ×tokens = most / fewest tokens across models. ×n beside a model: answers "
|
|
3445
|
+
"per question (×1: one answer decides each result, the roughest). Enter on a cell opens the "
|
|
3430
3446
|
"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(
|
|
3431
3447
|
f"{os.path.basename(os.path.dirname(p))} · {os.path.basename(p)[:-5]}" for p in sorted(self.superseded))
|
|
3432
3448
|
+ ". Only the new run's answers (◐) are shown for these.[/yellow]" if self.superseded else ""))
|
|
@@ -3468,6 +3484,7 @@ class ResultsScreen(Screen):
|
|
|
3468
3484
|
def hi(v, text, key, good):
|
|
3469
3485
|
ref = best.get(key) if good == "max" else low.get(key)
|
|
3470
3486
|
return f"[bold green]{text}[/bold green]" if v is not None and v == ref and len(rows) > 1 else text
|
|
3487
|
+
tags = {m: repeats_tag([t["cells"][m]["n"] for t in shared]) for m in models}
|
|
3471
3488
|
cells = []
|
|
3472
3489
|
for r in rows:
|
|
3473
3490
|
tt = r["total_secs"]
|
|
@@ -3488,7 +3505,10 @@ class ResultsScreen(Screen):
|
|
|
3488
3505
|
(f"[red]{r['cut']}[/red]" if r["cut"] else "0")
|
|
3489
3506
|
+ (f" [yellow]+{r['over']} over[/yellow]" if r.get("over") else "")])
|
|
3490
3507
|
table.add_columns(*header)
|
|
3491
|
-
|
|
3508
|
+
tag_w = max(Text.from_markup(tag).cell_len + 1 for tag in tags.values())
|
|
3509
|
+
cells = fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3 - tag_w,
|
|
3510
|
+
floor=24 - tag_w) # the column's floor counts the ×n too
|
|
3511
|
+
table.add_rows([[f"{rich_escape(c[0])} {tags[r['model']]}"] + c[1:] for c, r in zip(cells, rows)])
|
|
3492
3512
|
line = f"[b]Comparison[/b] over the {len(shared)} questions all {len(models)} answered"
|
|
3493
3513
|
if len(models) == 2:
|
|
3494
3514
|
h = compare.head_to_head(shared, *models)
|
|
@@ -3505,7 +3525,7 @@ class ResultsScreen(Screen):
|
|
|
3505
3525
|
title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
|
|
3506
3526
|
"per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
|
|
3507
3527
|
"they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Fewest / Fastest: questions won; ties count for both. "
|
|
3508
|
-
"Cut off: answers that hit max_tokens.[/dim]")
|
|
3528
|
+
"Cut off: answers that hit max_tokens. ×n beside a name: answers per question.[/dim]")
|
|
3509
3529
|
|
|
3510
3530
|
@staticmethod
|
|
3511
3531
|
def pq_cell(c, best, here):
|
|
@@ -13,6 +13,7 @@ Streams like llama.cpp: reasoning_content then content deltas, usage and timings
|
|
|
13
13
|
PROCESSING"), or of silence with --silent; --stall-before S: the same before the first token.
|
|
14
14
|
--die-after N: the process exits after N stream chunks (0: right after the headers), with no finish
|
|
15
15
|
reason or [DONE], like a server that crashes mid-request.
|
|
16
|
+
--loop TEXT: after the answer, TEXT 5000 times over (a model stuck in a loop).
|
|
16
17
|
An answer longer than the request's max_tokens (one token per word) is cut off with finish
|
|
17
18
|
"length". --max-output N also serves an OpenRouter-style endpoints list with that output limit.
|
|
18
19
|
"""
|
|
@@ -55,7 +56,7 @@ def reply_for(test, mode):
|
|
|
55
56
|
class Handler(BaseHTTPRequestHandler):
|
|
56
57
|
answers, mode, model, delay, max_output = {}, "oracle", "mock", 0.0, None
|
|
57
58
|
stall, stall_before, silent = 0.0, 0.0, False
|
|
58
|
-
die_after = None
|
|
59
|
+
die_after, loop = None, None
|
|
59
60
|
hold, answered = None, 0 # while the hold file exists, answers after the first wait (a run that stays going)
|
|
60
61
|
|
|
61
62
|
def log_message(self, *a):
|
|
@@ -94,6 +95,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
94
95
|
if self.mode == "unavailable": # like OpenRouter with no provider serving the model
|
|
95
96
|
return self._json({"error": {"message": f"No endpoints found for {self.model}.", "code": 404}}, 404)
|
|
96
97
|
text, calls = reply_for(test, self.mode)
|
|
98
|
+
if self.loop:
|
|
99
|
+
text += self.loop * 5000
|
|
97
100
|
finish = "tool_calls" if calls else "stop"
|
|
98
101
|
if req.get("max_tokens") and len(text.split()) > req["max_tokens"]:
|
|
99
102
|
text, finish = " ".join(text.split()[:req["max_tokens"]]), "length"
|
|
@@ -162,11 +165,12 @@ def main(argv=None):
|
|
|
162
165
|
p.add_argument("--silent", action="store_true")
|
|
163
166
|
p.add_argument("--die-after", type=int)
|
|
164
167
|
p.add_argument("--hold", help="while this file exists, every answer after the first waits")
|
|
168
|
+
p.add_argument("--loop")
|
|
165
169
|
a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
|
|
166
170
|
Handler.answers, Handler.mode, Handler.model, Handler.delay = load_answers(a.packs), a.mode, a.model, a.delay
|
|
167
171
|
Handler.max_output = a.max_output
|
|
168
172
|
Handler.stall, Handler.stall_before, Handler.silent = a.stall, a.stall_before, a.silent
|
|
169
|
-
Handler.die_after, Handler.hold = a.die_after, a.hold
|
|
173
|
+
Handler.die_after, Handler.hold, Handler.loop = a.die_after, a.hold, a.loop
|
|
170
174
|
srv = Server(("127.0.0.1", a.port), Handler)
|
|
171
175
|
print(f"mock server on 127.0.0.1:{a.port} ({a.mode}, {len(Handler.answers)} known questions)", flush=True)
|
|
172
176
|
try:
|
|
@@ -425,12 +425,12 @@ class PickTests(unittest.TestCase):
|
|
|
425
425
|
self.mock.stop()
|
|
426
426
|
self.tmp.cleanup()
|
|
427
427
|
|
|
428
|
-
def server(self, mode):
|
|
428
|
+
def server(self, mode, *flags):
|
|
429
429
|
"""(Re)start the mock on the same port: oracle answers right, wrong answers wrong."""
|
|
430
430
|
first = self.mock is None
|
|
431
431
|
if self.mock:
|
|
432
432
|
self.mock.stop()
|
|
433
|
-
self.mock = Mock(os.path.join(self.ws, "packs"), mode,
|
|
433
|
+
self.mock = Mock(os.path.join(self.ws, "packs"), mode, self.port, 0.0, *flags)
|
|
434
434
|
if first:
|
|
435
435
|
tuieval(self.ws, "add", "mock", "--server", "m", "--label", "m")
|
|
436
436
|
|
|
@@ -445,6 +445,20 @@ class PickTests(unittest.TestCase):
|
|
|
445
445
|
return subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
|
446
446
|
env=dict(os.environ, TUIEVAL_HOME=self.ws)).stdout.strip()
|
|
447
447
|
|
|
448
|
+
def test_a_looping_answer_is_stopped_and_failed(self):
|
|
449
|
+
self.server("oracle", "--loop", "</think>\n") # the right answer, then stuck
|
|
450
|
+
tuieval(self.ws, "run", "--tests", "apps:a")
|
|
451
|
+
rec = json.loads(read(os.path.join(self.ws, "results", "m", "apps.json")))["results"][0]
|
|
452
|
+
self.assertEqual((rec["finish"], rec["pass"], rec["loop"]["stream"]), ("loop", False, "answer"))
|
|
453
|
+
self.assertTrue(rec["reason"].startswith("LOOP:"), rec["reason"])
|
|
454
|
+
self.assertLess(len(rec["answer"]), 3000)
|
|
455
|
+
path = os.path.join(self.ws, "models.toml") # off: it runs to the end, as before
|
|
456
|
+
write(path, read(path).replace("[defaults]\n", "[defaults]\nloop_stop_chars = 0\n", 1))
|
|
457
|
+
tuieval(self.ws, "run", "--tests", "apps:a", "--force")
|
|
458
|
+
rec = json.loads(read(os.path.join(self.ws, "results", "m", "apps.json")))["results"][0]
|
|
459
|
+
self.assertEqual(rec["finish"], "stop")
|
|
460
|
+
self.assertNotIn("loop", rec)
|
|
461
|
+
|
|
448
462
|
def test_pick(self):
|
|
449
463
|
from tuieval import packs
|
|
450
464
|
p = packs.load_pack(os.path.join(self.ws, "packs", "apps"))
|
|
@@ -1725,7 +1739,7 @@ class PTAIndex(unittest.TestCase):
|
|
|
1725
1739
|
print(t.row_count, str(s.query_one("#fail-count", Static).render()))
|
|
1726
1740
|
s.fill_per_question()
|
|
1727
1741
|
pq = s.query_one("#per-question", DataTable)
|
|
1728
|
-
cols = [str(c.label) for c in pq.ordered_columns]
|
|
1742
|
+
cols = [str(c.label).split(" ×")[0] for c in pq.ordered_columns]
|
|
1729
1743
|
print(sorted(str(pq.get_row_at(i)[cols.index("close")]).split()[1] for i in range(pq.row_count)))
|
|
1730
1744
|
asyncio.run(go())
|
|
1731
1745
|
""")
|
|
@@ -1787,7 +1801,7 @@ class PTAIndex(unittest.TestCase):
|
|
|
1787
1801
|
s = app.screen
|
|
1788
1802
|
def state():
|
|
1789
1803
|
pq = s.query_one("#per-question", DataTable)
|
|
1790
|
-
cols = [str(c.label) for c in pq.ordered_columns][len(s.PQ_FIXED):]
|
|
1804
|
+
cols = [str(c.label).split(" ×")[0] for c in pq.ordered_columns][len(s.PQ_FIXED):]
|
|
1791
1805
|
return (sorted(app.pq_models), s.query_one("#pta-pack", Select).value,
|
|
1792
1806
|
str(s.query_one("#pq-models", Button).label), cols, s.query_one("#pq-shared").value)
|
|
1793
1807
|
s.query_one("#pq-pack", Select).value = "apps"
|
|
@@ -1941,7 +1955,7 @@ class PTAIndex(unittest.TestCase):
|
|
|
1941
1955
|
pq.move_cursor(row=0, column=col)
|
|
1942
1956
|
await pilot.press("enter")
|
|
1943
1957
|
await pilot.pause(0.3)
|
|
1944
|
-
opened[
|
|
1958
|
+
opened[pq.ordered_columns[col].label.plain.split(" ×")[0]] = app.screen.entries[0]["model"]
|
|
1945
1959
|
app.screen.dismiss()
|
|
1946
1960
|
await pilot.pause(0.2)
|
|
1947
1961
|
print(json.dumps({"rows": rows, "bars": "P parsimony" in tri, "disagree": [both, alone], "opened": opened,
|
|
@@ -2257,6 +2271,7 @@ class FirstRun(unittest.TestCase):
|
|
|
2257
2271
|
tuieval(self.ws, "add", "my-model", "--server", "local")
|
|
2258
2272
|
code = textwrap.dedent("""
|
|
2259
2273
|
import asyncio
|
|
2274
|
+
from rich.text import Text
|
|
2260
2275
|
from tuieval import compare
|
|
2261
2276
|
from tuieval.tui import EvalsApp, ResultsScreen
|
|
2262
2277
|
from textual.widgets import DataTable, TabbedContent
|
|
@@ -2274,14 +2289,56 @@ class FirstRun(unittest.TestCase):
|
|
|
2274
2289
|
app.screen.fill_pq_summary(compare.per_question(rows), models)
|
|
2275
2290
|
await pilot.pause(0.3)
|
|
2276
2291
|
t = app.screen.query_one("#pq-summary", DataTable)
|
|
2277
|
-
names = [
|
|
2292
|
+
names = [Text.from_markup(t.get_row_at(i)[0]).plain for i in range(t.row_count)]
|
|
2278
2293
|
print(t.row_count, t.region.height, t.virtual_size.width <= t.region.width,
|
|
2279
|
-
all(n.endswith("-q4_k_m-vllm") and "…" in n for n in names), len(set(names)))
|
|
2294
|
+
all(n.endswith("-q4_k_m-vllm ×1") and "…" in n for n in names), len(set(names)))
|
|
2280
2295
|
asyncio.run(go())
|
|
2281
2296
|
""")
|
|
2282
2297
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
|
2283
2298
|
env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
|
|
2284
|
-
|
|
2299
|
+
# every row, no scroll; a trimmed name keeps its ×n
|
|
2300
|
+
self.assertEqual(out.stdout.strip(), "12 13 True True 12", out.stderr[-2000:])
|
|
2301
|
+
|
|
2302
|
+
def test_per_question_shows_repeats(self):
|
|
2303
|
+
tuieval(self.ws, "add", "my-model", "--server", "local")
|
|
2304
|
+
code = textwrap.dedent("""
|
|
2305
|
+
import asyncio
|
|
2306
|
+
from tuieval.tui import EvalsApp, ResultsScreen
|
|
2307
|
+
from textual.widgets import DataTable, TabbedContent
|
|
2308
|
+
rows = [{"model": m, "suite": "p", "test": f"p: q{q}", "ok": True, "latency": 1000, "tokens": 10,
|
|
2309
|
+
"truncated": False, "gen_tps": 20.0, "prompt_tps": None, "repeat": r, "score": 1.0}
|
|
2310
|
+
for m, reps in (("once", 1), ("thrice", 3)) for q in range(2) for r in range(reps)]
|
|
2311
|
+
rows += [dict(rows[-1], test="p: q2", repeat=0)] # one more question, answered once
|
|
2312
|
+
async def go():
|
|
2313
|
+
app = EvalsApp({})
|
|
2314
|
+
async with app.run_test(size=(160, 40)) as pilot:
|
|
2315
|
+
await pilot.pause()
|
|
2316
|
+
app.push_screen(ResultsScreen())
|
|
2317
|
+
await pilot.pause(0.5)
|
|
2318
|
+
s = app.screen
|
|
2319
|
+
s.rows, s.partial_rows, s.superseded = rows, [], set()
|
|
2320
|
+
s.query_one(TabbedContent).active = "tab-per-question"
|
|
2321
|
+
await pilot.pause(0.3)
|
|
2322
|
+
s.fill_per_question()
|
|
2323
|
+
await pilot.pause(0.3)
|
|
2324
|
+
pq = s.query_one("#per-question", DataTable)
|
|
2325
|
+
heads = [c.label for c in pq.ordered_columns][len(s.PQ_FIXED):]
|
|
2326
|
+
print([h.plain for h in heads], [str(h.spans[0].style) for h in heads])
|
|
2327
|
+
app.result_sorts["per-question"] = ("thrice ×1-3", True) # as a click on its name does
|
|
2328
|
+
s.sort_table(pq)
|
|
2329
|
+
head = pq.ordered_columns[-1].label
|
|
2330
|
+
print(head.plain, str(head.spans[0].style))
|
|
2331
|
+
t = s.query_one("#pq-summary", DataTable)
|
|
2332
|
+
print([t.get_row_at(i)[0] for i in range(t.row_count)])
|
|
2333
|
+
asyncio.run(go())
|
|
2334
|
+
""")
|
|
2335
|
+
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
|
2336
|
+
env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
|
|
2337
|
+
self.assertEqual(out.stdout.strip().splitlines(), [
|
|
2338
|
+
"['once ×1', 'thrice ×1-3'] ['yellow', 'dim']", # over the questions shown
|
|
2339
|
+
"thrice ×1-3 ▼ dim", # sorting keeps the ×n's colour
|
|
2340
|
+
"['once [yellow]×1[/]', 'thrice [dim]×3[/]']", # Comparison: only the questions both answered
|
|
2341
|
+
], out.stderr[-2000:])
|
|
2285
2342
|
|
|
2286
2343
|
def test_results_open_on_pta_index(self):
|
|
2287
2344
|
tuieval(self.ws, "add", "my-model", "--server", "local")
|
|
@@ -3188,6 +3245,32 @@ class StallGuard(unittest.TestCase):
|
|
|
3188
3245
|
self.assertTrue(client.is_server_error(res["error"]), res["error"])
|
|
3189
3246
|
self.assertIsNone(res["finish"])
|
|
3190
3247
|
|
|
3248
|
+
def test_a_loop_is_stopped_early(self):
|
|
3249
|
+
from tuieval import client, graders
|
|
3250
|
+
res, took = self.ask("--loop", "</think>\n", timeout=30, loop_chars=2000)
|
|
3251
|
+
self.assertIsNone(res["error"])
|
|
3252
|
+
self.assertEqual((res["finish"], res["loop"]["stream"]), ("loop", "answer"))
|
|
3253
|
+
self.assertEqual(sorted(res["loop"]["unit"]), sorted("</think>\n")) # the unit, from wherever it was cut
|
|
3254
|
+
self.assertLess(len(res["answer"]), 3000) # 45,000 chars were coming
|
|
3255
|
+
g = graders.grade({"grader": "answer", "answer": "42"}, res["answer"], {"finish": "loop", "loop": res["loop"]})
|
|
3256
|
+
self.assertFalse(g["pass"])
|
|
3257
|
+
self.assertTrue(g["reason"].startswith("LOOP: the answer kept repeating"), g["reason"])
|
|
3258
|
+
res, _ = self.ask("--loop", "</think>\n", timeout=30) # off: the whole answer
|
|
3259
|
+
self.assertEqual(res["finish"], "stop")
|
|
3260
|
+
self.assertGreater(len(res["answer"]), 40000)
|
|
3261
|
+
|
|
3262
|
+
def test_what_counts_as_a_loop(self):
|
|
3263
|
+
from tuieval import client
|
|
3264
|
+
prose = " ".join(f"step {i}: check the value and move on." for i in range(200))
|
|
3265
|
+
self.assertIsNone(client.loop_unit(prose, 2000))
|
|
3266
|
+
self.assertEqual(client.loop_unit("intro " + "wait, " * 400, 2000), "wait, ")
|
|
3267
|
+
self.assertEqual(client.loop_unit("x" * 2500, 2000), "x")
|
|
3268
|
+
self.assertIsNone(client.loop_unit("| --- " * 500, 2000)) # a ruler: no letter or digit
|
|
3269
|
+
self.assertIsNone(client.loop_unit("wait, " * 300, 2000)) # shorter than min_chars
|
|
3270
|
+
line = "a sentence that is a good deal longer than sixty-four chars in all.\n"
|
|
3271
|
+
self.assertIsNone(client.loop_unit(line * 40, 2000)) # unit too long to call a loop
|
|
3272
|
+
self.assertIsNone(client.loop_unit("wait, " * 400, 0)) # off
|
|
3273
|
+
|
|
3191
3274
|
def test_finish_without_done_is_an_answer(self):
|
|
3192
3275
|
res, _ = self.ask("--die-after", "99", timeout=30) # every chunk, then gone before [DONE]
|
|
3193
3276
|
self.assertIsNone(res["error"])
|
|
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.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/pack.toml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev133 → tuieval-0.2.0.dev137}/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
|