tuieval 0.2.0.dev131__tar.gz → 0.2.0.dev135__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.dev131 → tuieval-0.2.0.dev135}/CHANGELOG.md +2 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/PKG-INFO +1 -1
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/models.md +7 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/builtin.toml +2 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/client.py +36 -1
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/compare.py +9 -5
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/engine.py +15 -4
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/graders/__init__.py +4 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/insights.py +1 -1
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/pta.py +28 -7
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/tui.py +7 -3
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/tests/mock_server.py +6 -2
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/tests/test_tuieval.py +58 -10
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/.gitignore +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/LICENSE +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/README.md +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/agent-packs.md +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/agents.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/cli.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/dates.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/logs.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/run_evals.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/yamlout.py +0 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
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, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
|
|
5
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.
|
|
6
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.
|
|
7
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.dev135
|
|
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
|
|
@@ -106,6 +106,7 @@ The **PTA index** puts parsimony (tokens), time and accuracy side by side, as a
|
|
|
106
106
|
- **T time**: the total time to answer those questions, as how many times the fastest model's, e.g. `28×` (★ in **time/answer**).
|
|
107
107
|
- **A accuracy**: the share of answers to those questions that passed. Answers cut off at `max_tokens` count as wrong, but show in orange after the green, with `(n% truncated)` beside the number and a **truncated** column in the table: those failures are a model out of tokens (overthinking), not a wrong answer yet. A pack's own `max_tokens` gives it more room.
|
|
108
108
|
- **Score**: partial credit, averaged over the same answers. Graders that can tell how close an answer came give it: the code grader the share of checks passed (5 of 6 is 83%, where accuracy counts it wrong), agent packs the share of hidden tests, the tool-call and RAG graders part marks. It shows beside the accuracy when the two differ, and in a **score** column. Order the models by it with the **By score** dropdown in the tab or `tuieval pta --sort score`; verdicts still use accuracy only.
|
|
109
|
+
- **Repeats**: beside each name, how many times the model answered each question compared (`×3`, or `×1-3` when it varies), also a **repeats** column here and in `tuieval compare`. A `×1` model (in yellow) answered each question once, so its accuracy is rougher: one answer more or less right moves it by 100 / questions points. Setup shows the same beside each pack in a model's results (`✓coding×3`).
|
|
109
110
|
- **Over budget**: some providers don't stop a model at `max_tokens` (thinking goes on past it), so the answer isn't cut off but the model had more room than the others. Answers that used more tokens than were sent as `max_tokens` show as `(n over budget)` beside the accuracy, an **over budget** column, `over` in Results → Per question and a note in the answer view. They're only flagged; `over_budget = "fail"` in `[defaults]` counts them as failed instead (stored results aren't changed, so switching back restores them).
|
|
110
111
|
|
|
111
112
|
The P and T bars are as long as the tokens and time used: the model that used the most fills its bar, the others in proportion (at least one block), so **shorter is better**. The A bar is the share of answers right, so **longer is better**; it's full only when every answer passed. Each heading says which way is better.
|
|
@@ -197,6 +198,10 @@ On Apple Silicon Macs, once the system's GPU allocations pass about half of RAM,
|
|
|
197
198
|
| `thinking_param` | `reasoning` to send `[sampling] enable_thinking` as OpenRouter's `reasoning.enabled` |
|
|
198
199
|
| `headers` | extra HTTP headers |
|
|
199
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
|
+
|
|
200
205
|
## Built-in servers and defaults
|
|
201
206
|
|
|
202
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.
|
|
@@ -209,6 +214,8 @@ A workspace's `models.toml` only needs what differs from these. `[defaults]` fil
|
|
|
209
214
|
repeat = 3
|
|
210
215
|
request_timeout_ms = 1800000 # per request; long thinking runs need it
|
|
211
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)
|
|
212
219
|
over_budget = "flag" # answers past the max_tokens sent (a provider that didn't stop them): "flag" or "fail"
|
|
213
220
|
# reference = "<label>" # the model Results → Insights compares against by default
|
|
214
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.dev135'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev135')
|
|
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"),
|
|
@@ -248,12 +249,14 @@ def scorecard(rows, rng=None):
|
|
|
248
249
|
suites = sorted({r["suite"] for r in rows})
|
|
249
250
|
extra = dict(graders.COLUMNS) # columns your graders add (graders.scorecard_column)
|
|
250
251
|
over = any(r.get("over_budget") for r in rows) # a column only when some answer ran past max_tokens
|
|
251
|
-
header = ["model"] + suites + ["overall", "95% CI", "mean score", "all repeats", *extra, "errors", "trunc",
|
|
252
|
+
header = ["model", "repeats"] + suites + ["overall", "95% CI", "mean score", "all repeats", *extra, "errors", "trunc",
|
|
252
253
|
*(["over budget"] if over else []), "rep"]
|
|
253
254
|
table = []
|
|
254
255
|
for m in models:
|
|
255
256
|
mine = [r for r in rows if r["model"] == m]
|
|
256
|
-
|
|
257
|
+
per_test = collections.Counter(r["test"] for r in mine) # answers per question: the repeats
|
|
258
|
+
few, most = min(per_test.values()), max(per_test.values())
|
|
259
|
+
line = [m, f"×{few}" if few == most else f"×{few}-{most}"]
|
|
257
260
|
for s in suites:
|
|
258
261
|
sub = [r for r in mine if r["suite"] == s]
|
|
259
262
|
line.append(f"{sum(r['ok'] for r in sub)}/{len(sub)}" if sub else "-")
|
|
@@ -271,7 +274,7 @@ def scorecard(rows, rng=None):
|
|
|
271
274
|
line.append("-" if cell is None else str(cell))
|
|
272
275
|
line += [str(sum(r["error"] for r in mine)), str(sum(r["truncated"] for r in mine)),
|
|
273
276
|
*([str(sum(bool(r.get("over_budget")) for r in mine))] if over else []),
|
|
274
|
-
str(sum(repetitive(r["output"]) for r in mine))]
|
|
277
|
+
str(sum(bool(r.get("loop")) or repetitive(r["output"]) for r in mine))]
|
|
275
278
|
table.append(line)
|
|
276
279
|
return header, table
|
|
277
280
|
|
|
@@ -678,8 +681,9 @@ def main(argv):
|
|
|
678
681
|
for note in settings_notes(infos):
|
|
679
682
|
print("NOTE:", note)
|
|
680
683
|
print_table(*scorecard(rows, rng))
|
|
681
|
-
print("\
|
|
682
|
-
print("
|
|
684
|
+
print("\nrepeats = answers per question (×1: one answer each, the roughest accuracy).")
|
|
685
|
+
print("mean score = average score, with partial credit; all repeats = tests passed on every repeat.")
|
|
686
|
+
print("trunc = hit max_tokens; rep = output ends in a degenerate repeated pattern (or was stopped live as a loop).")
|
|
683
687
|
header, table = by_difficulty(rows)
|
|
684
688
|
if any(r.get("difficulty") in LEVELS for r in rows):
|
|
685
689
|
print("\nPass rate by difficulty:")
|
|
@@ -989,6 +989,14 @@ class Engine:
|
|
|
989
989
|
need = {(t["id"], r) for t in pack.tests for r in range(self.certify_repeat(pack))}
|
|
990
990
|
return "certified" if need <= keys else "screened"
|
|
991
991
|
|
|
992
|
+
def result_repeats(self, label, pack_name, tier="certify"):
|
|
993
|
+
"""How many times each answered question of a stored result was asked: (fewest, most), or None."""
|
|
994
|
+
data = self._result(self.result_path(label, pack_name, tier))
|
|
995
|
+
if not data or not data["results"]:
|
|
996
|
+
return None
|
|
997
|
+
per_test = collections.Counter(r["test"] for r in data["results"])
|
|
998
|
+
return min(per_test.values()), max(per_test.values())
|
|
999
|
+
|
|
992
1000
|
def _partial_lines(self, path):
|
|
993
1001
|
"""(header, answers) of an unfinished run's .partial.jsonl, cached like _result;
|
|
994
1002
|
(None, []) if there is none."""
|
|
@@ -2392,6 +2400,7 @@ class Engine:
|
|
|
2392
2400
|
earlier_s=sum(s.get("wall_s") or 0 for s in job.earlier), sittings=len(job.earlier))
|
|
2393
2401
|
timeout = self.cfg["defaults"]["request_timeout_ms"] / 1000
|
|
2394
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
|
|
2395
2404
|
headers = self.request_headers(job.model)
|
|
2396
2405
|
# a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
|
|
2397
2406
|
tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
|
|
@@ -2421,7 +2430,7 @@ class Engine:
|
|
|
2421
2430
|
lambda kind, text: self.emit("delta", stream=kind, text=text,
|
|
2422
2431
|
label=job.label),
|
|
2423
2432
|
timeout=timeout, stream=self._stream, headers=headers,
|
|
2424
|
-
stall_s=stall_s)
|
|
2433
|
+
stall_s=stall_s, loop_chars=loop_chars)
|
|
2425
2434
|
except client.Cancelled:
|
|
2426
2435
|
self._check()
|
|
2427
2436
|
raise Cancelled()
|
|
@@ -2458,8 +2467,8 @@ class Engine:
|
|
|
2458
2467
|
if res["error"]:
|
|
2459
2468
|
g = {"pass": False, "score": 0.0, "reason": res["error"]}
|
|
2460
2469
|
else:
|
|
2461
|
-
g = graders.grade(test, res["answer"],
|
|
2462
|
-
|
|
2470
|
+
g = graders.grade(test, res["answer"], {"finish": res["finish"], "tool_calls": res["tool_calls"],
|
|
2471
|
+
"loop": res.get("loop")})
|
|
2463
2472
|
log_name = self._log_request(job, test, res, meta)
|
|
2464
2473
|
rec = {"test": test["id"], "description": test["description"], "category": test.get("category", ""),
|
|
2465
2474
|
"difficulty": test.get("difficulty", "unrated"),
|
|
@@ -2467,6 +2476,7 @@ class Engine:
|
|
|
2467
2476
|
"repeat": rep, "pass": g["pass"], "score": g["score"], "reason": g["reason"],
|
|
2468
2477
|
"severity": g.get("severity"), "answer": res["answer"], "tool_calls": res["tool_calls"],
|
|
2469
2478
|
"finish": res["finish"], "reasoning_chars": len(res["reasoning"]),
|
|
2479
|
+
**({"loop": res["loop"]} if res.get("loop") else {}),
|
|
2470
2480
|
"log": log_name, # logs/live/<log>: the reasoning, which the result doesn't keep
|
|
2471
2481
|
"answered_at": dates.now(),
|
|
2472
2482
|
**({"checks": g["checks"]} if "checks" in g else {}),
|
|
@@ -2590,7 +2600,8 @@ def regrade(path, packs_dir=None):
|
|
|
2590
2600
|
continue
|
|
2591
2601
|
if r["reason"].startswith(("server returned", "connection error")):
|
|
2592
2602
|
continue
|
|
2593
|
-
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")})
|
|
2594
2605
|
r.update({"pass": g["pass"], "score": g["score"], "reason": g["reason"], "severity": g.get("severity"),
|
|
2595
2606
|
"critical_test": bool(t.get("critical_trial")), "group": t.get("group")})
|
|
2596
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"
|
|
@@ -15,6 +15,9 @@ the others in proportion, at least one block), so shorter is better; the A bar:
|
|
|
15
15
|
P and T also carry a 0-100 score (100 for the best, POINTS_PER_DOUBLING points less for every
|
|
16
16
|
doubling), used to order models with the same accuracy.
|
|
17
17
|
|
|
18
|
+
Beside each name, the repeats: how many times it answered each of those questions ("×3", or "×1-3" when
|
|
19
|
+
it varies). A ×1 model's accuracy rests on one answer per question, so it is rougher than a ×3 one's.
|
|
20
|
+
|
|
18
21
|
Models are compared on the questions all of them answered, so one that skipped some never looks
|
|
19
22
|
faster. Server and connection errors aren't answers. By default a model with fewer than half as many
|
|
20
23
|
answers as the most-answered model is left out (listed, so it can be picked), or it would shrink the
|
|
@@ -54,7 +57,7 @@ SORTS = ("accuracy", "score")
|
|
|
54
57
|
|
|
55
58
|
def index(rows, models=None, memory=None, sort="accuracy"):
|
|
56
59
|
"""{"questions": n shared, "models": [{model, P, T, A, S, cut, cut_pct, tokens, total_s, passed, answers, alongside,
|
|
57
|
-
fewest, fastest, tps, ttft, memory_gb}],
|
|
60
|
+
fewest, fastest, tps, ttft, memory_gb, repeats: (fewest, most) answers per question}],
|
|
58
61
|
"left_out": [(model, questions answered)], "no_answers": [model], "most": questions answered by the
|
|
59
62
|
most-answered model}, best accuracy (sort="accuracy") or score (sort="score") first. S: the mean
|
|
60
63
|
score (partial credit) in %. models: the ones to compare (default: every model in rows
|
|
@@ -92,6 +95,7 @@ score (partial credit) in %. models: the ones to compare (default: every model i
|
|
|
92
95
|
"tps": compare.median(r.get("gen_tps") for c in cells for r in c["rows"]),
|
|
93
96
|
"ttft": compare.median(r.get("ttft") for c in cells for r in c["rows"]),
|
|
94
97
|
"memory_gb": (memory or {}).get(m),
|
|
98
|
+
"repeats": (min(c["n"] for c in cells), max(c["n"] for c in cells)) if cells else None,
|
|
95
99
|
# answers that ran while other models were served shared the machine's speed
|
|
96
100
|
"alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
|
|
97
101
|
fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
|
|
@@ -116,6 +120,14 @@ LETTERS = (("P", "parsimony (tokens)", "▼ shorter is better", "tokens", "token
|
|
|
116
120
|
("A", "accuracy", "▲ longer is better", "A", "A", "green"))
|
|
117
121
|
|
|
118
122
|
|
|
123
|
+
def repeats(x):
|
|
124
|
+
"""How many times a model answered each question compared: "×3", "×1-3", or "" when unknown."""
|
|
125
|
+
r = x.get("repeats")
|
|
126
|
+
if not r:
|
|
127
|
+
return ""
|
|
128
|
+
return f"×{r[0]}" if r[0] == r[1] else f"×{r[0]}-{r[1]}"
|
|
129
|
+
|
|
130
|
+
|
|
119
131
|
def times(v):
|
|
120
132
|
"""A multiple of the best: "1.0×", "2.4×", "28×"."""
|
|
121
133
|
return "-" if v is None else f"{v:.1f}×" if v < 9.95 else f"{v:.0f}×"
|
|
@@ -138,6 +150,8 @@ def bars(result, width=20, markup=True, click=None):
|
|
|
138
150
|
if not result["models"]:
|
|
139
151
|
return []
|
|
140
152
|
name_w = max(len(x["model"]) for x in result["models"])
|
|
153
|
+
rep_w = max(len(repeats(x)) for x in result["models"])
|
|
154
|
+
name_w += rep_w + 1 if rep_w else 0 # the repeats sit after the name, in its column
|
|
141
155
|
# each column: the bar, a space, "100%" or "9.8×" (6), then a gap; at least as wide as its heading
|
|
142
156
|
cell_w = max(width + 9, *(len(t) + 2 for k, name, better, *_ in LETTERS for t in (f"{k} {name}", better)))
|
|
143
157
|
pad = " " * (name_w + 2)
|
|
@@ -173,11 +187,14 @@ def bars(result, width=20, markup=True, click=None):
|
|
|
173
187
|
bar = (f"[{color}]{bar}[/]" + (f"[#ff8c00]{'█' * part}[/]" if part else "") + f"[grey37]{rest}[/]"
|
|
174
188
|
if markup else bar + cut_bar + rest)
|
|
175
189
|
cells.append(f"{bar} {num:>6}" + " " * max(0, cell_w - width - 1 - max(6, len(num))))
|
|
176
|
-
|
|
190
|
+
tag = repeats(x)
|
|
191
|
+
name = f"{x['model'] + (' ' + tag if tag else ''):<{name_w}}"
|
|
177
192
|
if markup:
|
|
178
|
-
pad_w = name_w - len(x["model"])
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
pad_w = name_w - len(x["model"]) - (len(tag) + 1 if tag else 0)
|
|
194
|
+
# a single answer per question in yellow: its accuracy is the roughest
|
|
195
|
+
tag = f" [{'yellow' if x['repeats'][1] == 1 else 'dim'}]{tag}[/]" if tag else ""
|
|
196
|
+
name = ((f"[@click={click}({row})]{rich_escape(x['model'])}[/]" if click else rich_escape(x["model"]))
|
|
197
|
+
+ tag + " " * pad_w)
|
|
181
198
|
lines.append((f"{name} " + "".join(cells)).rstrip())
|
|
182
199
|
return lines
|
|
183
200
|
|
|
@@ -202,9 +219,9 @@ def table(result):
|
|
|
202
219
|
"""(header, rows) for a plain table of the index."""
|
|
203
220
|
n = result["questions"] or 1
|
|
204
221
|
over = any(x.get("over") for x in result["models"]) # a column only when some answer ran past max_tokens
|
|
205
|
-
header = ["model", "tokens/answer", "time/answer", "answers right", "score", "truncated", *(["over budget"] if over else []),
|
|
222
|
+
header = ["model", "repeats", "tokens/answer", "time/answer", "answers right", "score", "truncated", *(["over budget"] if over else []),
|
|
206
223
|
"tok/s", "TTFT s", "memory GB"]
|
|
207
|
-
rows = [[x["model"], f"{round(x['tokens'] / n):,}" + (" ★" if x.get("fewest") else ""),
|
|
224
|
+
rows = [[x["model"], repeats(x) or "-", f"{round(x['tokens'] / n):,}" + (" ★" if x.get("fewest") else ""),
|
|
208
225
|
fmt_secs(x["total_s"] / n) + (" ★" if x.get("fastest") else ""),
|
|
209
226
|
f"{x['passed']}/{x['answers']}", "-" if x.get("S") is None else compare.pct(x["S"] / 100),
|
|
210
227
|
f"{x.get('cut') or 0}/{x['answers']}",
|
|
@@ -243,6 +260,10 @@ def scope_note(result, models):
|
|
|
243
260
|
"P parsimony (tokens) and T time: bars as long as the tokens / time used (shorter is better), "
|
|
244
261
|
"and how many times the leanest / fastest model's (1.0×, ★ in the table). A accuracy: answers right "
|
|
245
262
|
"(longer is better); score: partial credit, e.g. 5 of 6 checks passed is 83% (shown when it differs)"
|
|
263
|
+
+ (f"; ×n beside a name: answers per question. ×1 models answered each question once, so one "
|
|
264
|
+
f"answer more or less right moves their accuracy by {100 / result['questions']:.0f} points; "
|
|
265
|
+
"repeats steady it" if len({x.get("repeats") for x in result["models"]}) > 1
|
|
266
|
+
or any(x.get("repeats") and x["repeats"][1] == 1 for x in result["models"]) else "")
|
|
246
267
|
+ ("; orange, (n% truncated): answers cut off at max_tokens. They count as wrong, but the model ran "
|
|
247
268
|
"out of tokens rather than answering wrong." if any(x.get("cut") for x in result["models"]) else ".")
|
|
248
269
|
+ (" Over budget: answers that used more tokens than the max_tokens sent, because the provider didn't "
|
|
@@ -624,7 +624,8 @@ class AnswerScreen(ModalScreen):
|
|
|
624
624
|
quant = f" [magenta]{rec['quantization']}[/magenta]" if rec.get("quantization") else ""
|
|
625
625
|
finish = rec.get("finish") or "?"
|
|
626
626
|
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"
|
|
627
|
+
finish = (f"[bold red]{finish} (cut off at {limit})[/]" if finish == "length"
|
|
628
|
+
else f"[bold red]loop (stopped: it kept repeating itself)[/]" if finish == "loop" else finish)
|
|
628
629
|
toks = rec.get("completion_tokens")
|
|
629
630
|
split = ", ".join(f"{k} {rec[k + '_tokens']:,}" for k in ("reasoning", "answer")
|
|
630
631
|
if rec.get(k + "_tokens") is not None)
|
|
@@ -937,7 +938,10 @@ class SetupScreen(Screen):
|
|
|
937
938
|
for n in e.packs:
|
|
938
939
|
st = e.result_status(label, n)
|
|
939
940
|
if st:
|
|
940
|
-
|
|
941
|
+
reps = e.result_repeats(label, n) if st in ("certified", "screened") else None
|
|
942
|
+
times = ("" if not reps else f"[dim]×{reps[0]}[/dim]" if reps[0] == reps[1]
|
|
943
|
+
else f"[dim]×{reps[0]}-{reps[1]}[/dim]")
|
|
944
|
+
packs.append(f"{mark.get(st, '[yellow]~[/yellow]')}{n}{times}")
|
|
941
945
|
if st.startswith("outdated"):
|
|
942
946
|
why.setdefault(st.removeprefix("outdated: "), []).append(n)
|
|
943
947
|
if why: # each reason once, not once per pack
|
|
@@ -951,7 +955,7 @@ class SetupScreen(Screen):
|
|
|
951
955
|
hidden = (f" [cyan]{' '.join(tags)}[/cyan]" if tags else "") + hidden
|
|
952
956
|
detail.update(f"[b]{label}[/b] [dim]{m['server']} · {m.get('model') or ''}[/dim]"
|
|
953
957
|
f"{serving_badge(e, m, not e.has_results())}{hidden}\n{verdicts}\n"
|
|
954
|
-
+ ("[dim]results (✓ certified ◐ screened ~ outdated … partial):[/dim] " + " ".join(packs)
|
|
958
|
+
+ ("[dim]results (✓ certified ◐ screened ~ outdated … partial; ×n answers per question):[/dim] " + " ".join(packs)
|
|
955
959
|
if packs else "[dim]no results yet[/dim]"))
|
|
956
960
|
|
|
957
961
|
@on(Checkbox.Changed, "#show-hidden")
|
|
@@ -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"))
|
|
@@ -502,6 +516,9 @@ class PickTests(unittest.TestCase):
|
|
|
502
516
|
self.assertIn("done earlier", out) # nothing missing: not re-asked
|
|
503
517
|
tuieval(self.ws, "run", "--tests", "apps:d", "--tier", "certify")
|
|
504
518
|
self.assertEqual(self.status(), "certified")
|
|
519
|
+
code = "from tuieval import engine; print(engine.Engine().result_repeats('m', 'apps'))"
|
|
520
|
+
self.assertEqual(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, # ✓×1 on Setup
|
|
521
|
+
env=dict(os.environ, TUIEVAL_HOME=self.ws)).stdout.strip(), "(1, 1)")
|
|
505
522
|
|
|
506
523
|
self.server("wrong")
|
|
507
524
|
tuieval(self.ws, "run", "--tests", "apps:b", "--force")
|
|
@@ -1555,7 +1572,7 @@ class PTAIndex(unittest.TestCase):
|
|
|
1555
1572
|
scale, and accuracy, all over the questions every compared model answered."""
|
|
1556
1573
|
|
|
1557
1574
|
def test_index_compares_shared_questions(self):
|
|
1558
|
-
from tuieval import pta
|
|
1575
|
+
from tuieval import compare, pta
|
|
1559
1576
|
|
|
1560
1577
|
def row(model, test, ok, secs, repeat=0):
|
|
1561
1578
|
return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "latency": secs * 1000,
|
|
@@ -1574,9 +1591,9 @@ class PTAIndex(unittest.TestCase):
|
|
|
1574
1591
|
self.assertEqual((by["slow"]["P"], by["fast"]["P"]), (100, 70)) # 4x the tokens: two doublings
|
|
1575
1592
|
self.assertEqual((pta.score(2, 1), pta.score(1000, 1)), (85, 0))
|
|
1576
1593
|
header, table = pta.table(res)
|
|
1577
|
-
self.assertEqual(header[1:
|
|
1594
|
+
self.assertEqual(header[1:5], ["repeats", "tokens/answer", "time/answer", "answers right"])
|
|
1578
1595
|
cells = {r[0]: r for r in table}
|
|
1579
|
-
self.assertEqual((cells["slow"][
|
|
1596
|
+
self.assertEqual((cells["slow"][2], cells["fast"][2:4]), ("10 ★", ["40", "2.0s ★"])) # per answer
|
|
1580
1597
|
bars = pta.bars(res, 10, markup=False)
|
|
1581
1598
|
self.assertEqual(bars[0].split(), ["P", "parsimony", "(tokens)", "T", "time", "A", "accuracy"])
|
|
1582
1599
|
self.assertEqual(bars[1].split(), ["▼", "shorter", "is", "better"] * 2 + ["▲", "longer", "is", "better"])
|
|
@@ -1585,8 +1602,13 @@ class PTAIndex(unittest.TestCase):
|
|
|
1585
1602
|
(fast.index("██████████"), fast.index("███░░░░░░░"))) # its heading
|
|
1586
1603
|
# P and T bars as long as tokens and time (shorter is better): fast used 4x the tokens of slow but
|
|
1587
1604
|
# took 4 s against 13 s, so it has the longest token bar and the shortest time bar
|
|
1588
|
-
self.assertEqual(fast.split(), ["fast", "██████████", "4.0×", "███░░░░░░░", "1.0×", "█████░░░░░", "50.0%"])
|
|
1589
|
-
self.assertEqual(slow.split()[1:
|
|
1605
|
+
self.assertEqual(fast.split(), ["fast", "×1", "██████████", "4.0×", "███░░░░░░░", "1.0×", "█████░░░░░", "50.0%"])
|
|
1606
|
+
self.assertEqual(slow.split()[1:6], ["×1-2", "██░░░░░░░░", "1.0×", "██████████", "3.2×"]) # ×: answers per question
|
|
1607
|
+
self.assertEqual((cells["slow"][1], cells["fast"][1]), ("×1-2", "×1"))
|
|
1608
|
+
self.assertIn("×1 models answered each question once", pta.scope_note(res, ["slow", "fast"]))
|
|
1609
|
+
full = [dict(r, score=float(r["ok"]), error=False, output="") for r in rows]
|
|
1610
|
+
header, table = compare.scorecard(full)
|
|
1611
|
+
self.assertEqual({r[0]: r[header.index("repeats")] for r in table}, {"slow": "×1-2", "fast": "×1"})
|
|
1590
1612
|
self.assertEqual((pta.times(1), pta.times(9.84), pta.times(27.9)), ("1.0×", "9.8×", "28×"))
|
|
1591
1613
|
self.assertEqual((by["fast"]["A"], by["slow"]["A"]), (50, 100))
|
|
1592
1614
|
self.assertEqual([x["model"] for x in res["models"]], ["slow", "fast"]) # best accuracy first
|
|
@@ -1618,8 +1640,8 @@ class PTAIndex(unittest.TestCase):
|
|
|
1618
1640
|
self.assertEqual(bars["plain"][-2:], ["██████░░░░", "60.0%"]) # none truncated: nothing beside it
|
|
1619
1641
|
self.assertIn("[#ff8c00]██[/]", pta.bars(res, 10)[2] + pta.bars(res, 10)[3])
|
|
1620
1642
|
header, table = pta.table(res)
|
|
1621
|
-
self.assertEqual(header[
|
|
1622
|
-
self.assertEqual({r[0]: r[
|
|
1643
|
+
self.assertEqual(header[5:7], ["score", "truncated"])
|
|
1644
|
+
self.assertEqual({r[0]: r[6] for r in table}, {"thinker": "2/10", "plain": "0/10"})
|
|
1623
1645
|
self.assertIn("truncated", pta.scope_note(res, ["thinker", "plain"]))
|
|
1624
1646
|
self.assertNotIn("truncated", pta.scope_note(pta.index(rows[10:]), ["plain"]))
|
|
1625
1647
|
|
|
@@ -1949,7 +1971,7 @@ class PTAIndex(unittest.TestCase):
|
|
|
1949
1971
|
self.assertGreaterEqual(len(got["opened"]), 2)
|
|
1950
1972
|
self.assertEqual(got["opened"], {m: m for m in got["opened"]}) # a cell opens its column's model
|
|
1951
1973
|
self.assertEqual(got["tab"], "tab-pta") # Results opens on the PTA index
|
|
1952
|
-
self.assertEqual([(r[0], r[
|
|
1974
|
+
self.assertEqual([(r[0], r[4]) for r in got["rows"]][0], ("good", "4/4"))
|
|
1953
1975
|
s = got["sorts"] # a click on a column name sorts by it, a second click reverses
|
|
1954
1976
|
self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
|
|
1955
1977
|
self.assertIn("answers right ▼", s["labels"])
|
|
@@ -3180,6 +3202,32 @@ class StallGuard(unittest.TestCase):
|
|
|
3180
3202
|
self.assertTrue(client.is_server_error(res["error"]), res["error"])
|
|
3181
3203
|
self.assertIsNone(res["finish"])
|
|
3182
3204
|
|
|
3205
|
+
def test_a_loop_is_stopped_early(self):
|
|
3206
|
+
from tuieval import client, graders
|
|
3207
|
+
res, took = self.ask("--loop", "</think>\n", timeout=30, loop_chars=2000)
|
|
3208
|
+
self.assertIsNone(res["error"])
|
|
3209
|
+
self.assertEqual((res["finish"], res["loop"]["stream"]), ("loop", "answer"))
|
|
3210
|
+
self.assertEqual(sorted(res["loop"]["unit"]), sorted("</think>\n")) # the unit, from wherever it was cut
|
|
3211
|
+
self.assertLess(len(res["answer"]), 3000) # 45,000 chars were coming
|
|
3212
|
+
g = graders.grade({"grader": "answer", "answer": "42"}, res["answer"], {"finish": "loop", "loop": res["loop"]})
|
|
3213
|
+
self.assertFalse(g["pass"])
|
|
3214
|
+
self.assertTrue(g["reason"].startswith("LOOP: the answer kept repeating"), g["reason"])
|
|
3215
|
+
res, _ = self.ask("--loop", "</think>\n", timeout=30) # off: the whole answer
|
|
3216
|
+
self.assertEqual(res["finish"], "stop")
|
|
3217
|
+
self.assertGreater(len(res["answer"]), 40000)
|
|
3218
|
+
|
|
3219
|
+
def test_what_counts_as_a_loop(self):
|
|
3220
|
+
from tuieval import client
|
|
3221
|
+
prose = " ".join(f"step {i}: check the value and move on." for i in range(200))
|
|
3222
|
+
self.assertIsNone(client.loop_unit(prose, 2000))
|
|
3223
|
+
self.assertEqual(client.loop_unit("intro " + "wait, " * 400, 2000), "wait, ")
|
|
3224
|
+
self.assertEqual(client.loop_unit("x" * 2500, 2000), "x")
|
|
3225
|
+
self.assertIsNone(client.loop_unit("| --- " * 500, 2000)) # a ruler: no letter or digit
|
|
3226
|
+
self.assertIsNone(client.loop_unit("wait, " * 300, 2000)) # shorter than min_chars
|
|
3227
|
+
line = "a sentence that is a good deal longer than sixty-four chars in all.\n"
|
|
3228
|
+
self.assertIsNone(client.loop_unit(line * 40, 2000)) # unit too long to call a loop
|
|
3229
|
+
self.assertIsNone(client.loop_unit("wait, " * 400, 0)) # off
|
|
3230
|
+
|
|
3183
3231
|
def test_finish_without_done_is_an_answer(self):
|
|
3184
3232
|
res, _ = self.ask("--die-after", "99", timeout=30) # every chunk, then gone before [DONE]
|
|
3185
3233
|
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
|
{tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/pack.toml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev131 → tuieval-0.2.0.dev135}/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
|