tuieval 0.2.0.dev121__tar.gz → 0.2.0.dev123__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.
Files changed (80) hide show
  1. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/models.md +2 -0
  4. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/builtin.toml +1 -0
  6. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/client.py +20 -0
  7. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/compare.py +35 -7
  8. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/engine.py +15 -3
  9. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/insights.py +1 -1
  10. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/pta.py +13 -2
  11. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/tui.py +7 -2
  12. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/tests/test_tuieval.py +75 -0
  13. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/.gitignore +0 -0
  14. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/LICENSE +0 -0
  15. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/README.md +0 -0
  16. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/RELEASING.md +0 -0
  17. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/README.md +0 -0
  18. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/favicon.ico +0 -0
  19. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/readme-header.png +0 -0
  20. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/social-preview.png +0 -0
  21. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-1024.png +0 -0
  22. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-128.png +0 -0
  23. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-16.png +0 -0
  24. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-16.svg +0 -0
  25. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-256.png +0 -0
  26. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-32.png +0 -0
  27. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-48.png +0 -0
  28. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-512.png +0 -0
  29. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-64.png +0 -0
  30. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  31. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/brand/tuieval-icon.svg +0 -0
  32. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/tui-answer-reasoning.png +0 -0
  33. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/tui-run-reasoning.png +0 -0
  34. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/images/tui-setup.png +0 -0
  35. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/docs/writing-packs.md +0 -0
  36. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/pyproject.toml +0 -0
  37. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/__init__.py +0 -0
  38. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/__main__.py +0 -0
  39. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/cli.py +0 -0
  40. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/dates.py +0 -0
  41. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/doctor.py +0 -0
  42. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/export.py +0 -0
  43. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/graders/__init__.py +0 -0
  44. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/graders/answer.py +0 -0
  45. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/graders/code.py +0 -0
  46. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/graders/rag.py +0 -0
  47. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/graders/reply.py +0 -0
  48. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/graders/tool_call.py +0 -0
  49. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/logs.py +0 -0
  50. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/machines.py +0 -0
  51. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/packs.py +0 -0
  53. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/profiles.py +0 -0
  54. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/run_evals.py +0 -0
  56. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/scaffold.py +0 -0
  57. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/selftest.py +0 -0
  58. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/models.toml +0 -0
  59. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  60. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/answer/system.txt +0 -0
  61. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  62. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/code/pack.toml +0 -0
  63. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/code/system.txt +0 -0
  64. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  66. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/rag/system.txt +0 -0
  67. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  69. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/reply/system.txt +0 -0
  70. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  71. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  72. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  73. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  74. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  75. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/tune.py +0 -0
  76. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/verdict.py +0 -0
  77. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/watch_proxy.py +0 -0
  78. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/workspace.py +0 -0
  79. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/src/tuieval/yamlout.py +0 -0
  80. {tuieval-0.2.0.dev121 → tuieval-0.2.0.dev123}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Answers that used more tokens than the `max_tokens` sent (a provider that didn't stop them) are marked over budget: `(n over budget)` in the PTA index, an **over budget** column, `over` in Per question and a note in the answer view. `[defaults] over_budget = "fail"` fails them instead. Works on stored results.
5
6
  - Fix: Results → Per question opened the answers of the model one column to the right of the cell picked, and crashed on the last model's column.
6
7
  - Fix: `v` on the run screen switches to the last model still running when the stream shows one that has finished.
7
8
  - Setup opens with no models ticked: at launch, after New run, and after Back from a run or tune. Packs and options are still remembered.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev121
3
+ Version: 0.2.0.dev123
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
@@ -105,6 +105,7 @@ The **PTA index** puts parsimony (tokens), time and accuracy side by side, as a
105
105
  - **P parsimony (tokens)**: the tokens used, reasoning and answer, on every question compared (each question at its median over repeats). Beside the bar: how many times the leanest model's, e.g. `2.4×` (`1.0×` is the leanest, ★ in the table's **tokens/answer**). Unlike time it doesn't depend on your machine: it's how much a model says to get to an answer, which is what you pay for on an API and what fills its context.
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
+ - **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).
108
109
 
109
110
  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.
110
111
 
@@ -207,6 +208,7 @@ A workspace's `models.toml` only needs what differs from these. `[defaults]` fil
207
208
  repeat = 3
208
209
  request_timeout_ms = 1800000 # per request; long thinking runs need it
209
210
  stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
211
+ over_budget = "flag" # answers past the max_tokens sent (a provider that didn't stop them): "flag" or "fail"
210
212
  # reference = "<label>" # the model Results → Insights compares against by default
211
213
  ready_timeout_s = 1200 # how long a model may take to load before giving up
212
214
  model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
@@ -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.dev121'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev121')
21
+ __version__ = version = '0.2.0.dev123'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev123')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -6,6 +6,7 @@
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
+ over_budget = "flag" # answers past the max_tokens sent (a provider that didn't stop them): "flag" or "fail"
9
10
  ready_timeout_s = 1200 # how long a model may take to load before giving up
10
11
  model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
11
12
 
@@ -42,6 +42,26 @@ def server_error_row(rec):
42
42
  return not rec.get("pass") and (is_server_error(rec.get("reason")) or is_unavailable(rec.get("reason")))
43
43
 
44
44
 
45
+ OVER_BUDGET_MODES = ("flag", "fail")
46
+
47
+
48
+ def mark_over_budget(records, limit, mode="flag"):
49
+ """Answers that used more tokens than the max_tokens sent and weren't cut off there: some providers
50
+ don't stop thinking at the limit. Each gets `over_budget` = the limit; with mode "fail", a passing
51
+ one fails. Estimated token counts are never flagged."""
52
+ if not limit:
53
+ return records
54
+ for r in records:
55
+ if r.get("finish") == "length" or r.get("tokens_estimated") or (r.get("completion_tokens") or 0) <= limit:
56
+ continue
57
+ r["over_budget"] = limit
58
+ if mode == "fail" and r.get("pass"):
59
+ r.update({"pass": False, "score": 0.0, "severity": None,
60
+ "reason": f"OVER BUDGET: {r['completion_tokens']:,} tokens, over the {limit:,} limit sent; "
61
+ + r.get("reason", "")})
62
+ return records
63
+
64
+
45
65
  class Cancelled(Exception):
46
66
  pass
47
67
 
@@ -51,6 +51,8 @@ def _load_native(data):
51
51
  "ok": bool(r["pass"]), "score": float(r["score"]), "reason": r["reason"],
52
52
  "error": r["reason"].startswith(("server returned", "connection error")),
53
53
  "truncated": r.get("finish") == "length", "output": r.get("answer") or "",
54
+ # tokens past the max_tokens sent, which the provider didn't stop (client.mark_over_budget)
55
+ "over_budget": r.get("over_budget"),
54
56
  "tokens": r.get("completion_tokens") or 0, "reasoning_tokens": r.get("reasoning_tokens"),
55
57
  # a reused (cached) prompt makes time-to-first-token and prompt speed look faster than they are
56
58
  "answer_tokens": r.get("answer_tokens"), "ttft": None if r.get("cached_tokens") else r.get("ttft_s"),
@@ -67,13 +69,16 @@ def _load_native(data):
67
69
 
68
70
  def load(paths):
69
71
  """(rows, infos): one row per answer; one info per (model, result file)."""
72
+ from . import engine
70
73
  rows, infos = [], []
74
+ mode = over_budget_mode(paths)
71
75
  for path in paths:
72
76
  with open(path) as f:
73
77
  data = json.load(f)
74
78
  if data.get("format") != "evals/1":
75
79
  continue
76
80
  data["results"] = [r for r in data["results"] if not client.server_error_row(r)]
81
+ client.mark_over_budget(data["results"], engine.sent_max_tokens(data), mode)
77
82
  r, i = _load_native(data)
78
83
  for row, rec in zip(r, data["results"]):
79
84
  row.update(raw=rec, path=path, pack_fp=data["pack"]["fingerprint"])
@@ -93,17 +98,31 @@ def load(paths):
93
98
  return rows, infos
94
99
 
95
100
 
101
+ def _configs(paths):
102
+ """The models.toml configs of the workspaces these results/<label>/<pack>.json are from (unreadable
103
+ ones left out)."""
104
+ from . import engine
105
+ out = []
106
+ for root in sorted({os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(p)))) for p in paths}):
107
+ try:
108
+ out.append(engine.load_config(os.path.join(root, "models.toml")))
109
+ except Exception:
110
+ continue
111
+ return out
112
+
113
+
114
+ def over_budget_mode(paths):
115
+ """[defaults] over_budget of the workspace(s) the results are from: "fail" if any says so."""
116
+ return "fail" if any(c["defaults"]["over_budget"] == "fail" for c in _configs(paths)) else "flag"
117
+
118
+
96
119
  def configured_hosted(paths):
97
120
  """(labels, label prefixes) models.toml says are hosted, in the workspaces these results/<label>/
98
121
  <pack>.json are from: its hosted models, and the prefix of models named at run time on a hosted
99
122
  server (e.g. "or-"). Covers hosted models whose results aren't loaded."""
100
123
  from . import engine
101
124
  labels, prefixes = set(), set()
102
- for root in {os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(p)))) for p in paths}:
103
- try:
104
- cfg = engine.load_config(os.path.join(root, "models.toml"))
105
- except Exception:
106
- continue # no readable models.toml there: the results' own marks still apply
125
+ for cfg in _configs(paths): # no readable models.toml: the results' own marks still apply
107
126
  labels |= {m["label"] for m in cfg["models"] if engine.hosted(cfg, m)}
108
127
  prefixes |= {engine.remote_label(n, s, "") for n, s in cfg["servers"].items()
109
128
  if s.get("any_model") and engine.hosted(cfg, {"server": n})}
@@ -125,6 +144,7 @@ def load_partials(results_dir=None):
125
144
  <pack>.json.partial.jsonl, marked partial. They never count toward scores or verdicts."""
126
145
  results_dir = results_dir or workspace.path("results")
127
146
  rows = []
147
+ mode = over_budget_mode([os.path.join(results_dir, "x", "x.json")])
128
148
  for path in sorted(glob.glob(os.path.join(results_dir, "*", "*" + PARTIAL_SUFFIX))):
129
149
  label = os.path.basename(os.path.dirname(path))
130
150
  if label in ("smoke", "archive"):
@@ -137,6 +157,9 @@ def load_partials(results_dir=None):
137
157
  if len(lines) < 2:
138
158
  continue
139
159
  head, recs = lines[0], [r for r in lines[1:] if not client.server_error_row(r)]
160
+ # the limit sent: recorded in the header (older ones: the model's own)
161
+ client.mark_over_budget(recs, head.get("max_tokens") or ((head.get("settings") or {}).get("sampling") or {})
162
+ .get("max_tokens"), mode)
140
163
  data = {"model": {"label": label}, "pack": {"name": os.path.basename(path)[:-len(PARTIAL_SUFFIX)],
141
164
  "fingerprint": head.get("pack_fingerprint")},
142
165
  "run": {}, "results": recs}
@@ -222,7 +245,9 @@ def scorecard(rows, rng=None):
222
245
  models = sorted({r["model"] for r in rows})
223
246
  suites = sorted({r["suite"] for r in rows})
224
247
  extra = dict(graders.COLUMNS) # columns your graders add (graders.scorecard_column)
225
- header = ["model"] + suites + ["overall", "95% CI", "mean score", "all repeats", *extra, "errors", "trunc", "rep"]
248
+ over = any(r.get("over_budget") for r in rows) # a column only when some answer ran past max_tokens
249
+ header = ["model"] + suites + ["overall", "95% CI", "mean score", "all repeats", *extra, "errors", "trunc",
250
+ *(["over budget"] if over else []), "rep"]
226
251
  table = []
227
252
  for m in models:
228
253
  mine = [r for r in rows if r["model"] == m]
@@ -243,6 +268,7 @@ def scorecard(rows, rng=None):
243
268
  cell = "error"
244
269
  line.append("-" if cell is None else str(cell))
245
270
  line += [str(sum(r["error"] for r in mine)), str(sum(r["truncated"] for r in mine)),
271
+ *([str(sum(bool(r.get("over_budget")) for r in mine))] if over else []),
246
272
  str(sum(repetitive(r["output"]) for r in mine))]
247
273
  table.append(line)
248
274
  return header, table
@@ -339,6 +365,7 @@ def per_question(rows):
339
365
  for c in t["cells"].values():
340
366
  v = sorted(c["rows"], key=lambda r: r.get("repeat", 0))
341
367
  c.update(rows=v, n=len(v), passed=sum(r["ok"] for r in v), truncated=sum(r["truncated"] for r in v),
368
+ over=sum(bool(r.get("over_budget")) for r in v),
342
369
  partial=any(r.get("partial") for r in v),
343
370
  tokens=median(r["tokens"] or None for r in v), secs=median(r["latency"] / 1000 or None for r in v),
344
371
  tps=median(r["gen_tps"] for r in v))
@@ -386,7 +413,8 @@ def compare_models(tests, models):
386
413
  "med_tokens": median(c["tokens"] for c in cs), "med_secs": median(c["secs"] for c in cs),
387
414
  "total_secs": sum(c["secs"] or 0 for c in cs) or None,
388
415
  "tokens_per_pass": total / passed if passed else None,
389
- "fewest": fewest, "fastest": fastest, "cut": sum(c["truncated"] for c in cs)})
416
+ "fewest": fewest, "fastest": fastest, "cut": sum(c["truncated"] for c in cs),
417
+ "over": sum(c.get("over", 0) for c in cs)})
390
418
  return out
391
419
 
392
420
 
@@ -107,6 +107,9 @@ def load_config(path):
107
107
  cfg = tomllib.load(f)
108
108
  builtin = builtin_config()
109
109
  cfg["defaults"] = {**builtin["defaults"], **cfg.get("defaults", {})}
110
+ if cfg["defaults"]["over_budget"] not in client.OVER_BUDGET_MODES:
111
+ raise ConfigError(f"{path}: [defaults] over_budget must be one of "
112
+ f"{', '.join(map(repr, client.OVER_BUDGET_MODES))}, not {cfg['defaults']['over_budget']!r}")
110
113
  cfg.setdefault("sampling", builtin["sampling"])
111
114
  own = cfg.get("servers", {})
112
115
  cfg["servers"] = {**builtin["servers"], **own}
@@ -528,6 +531,11 @@ def read_result(path):
528
531
  return data
529
532
 
530
533
 
534
+ def sent_max_tokens(data):
535
+ """The max_tokens a result file's requests were sent with: the pack's own, else the model's."""
536
+ return (data.get("pack") or {}).get("max_tokens") or (data.get("settings") or {}).get("max_tokens")
537
+
538
+
531
539
  def server_error(log, start):
532
540
  """Why a server stopped while loading: the last error line it wrote since start, when it
533
541
  names one (some servers refuse a context that does not fit with one)."""
@@ -925,9 +933,13 @@ class Engine:
925
933
  return cached[1]
926
934
 
927
935
  def records(self, label, pack_name):
928
- """Stored result rows for a model and pack, or [] (smoke runs excluded)."""
936
+ """Stored result rows for a model and pack, or [] (smoke runs excluded), as scored: copies, with
937
+ answers past the max_tokens sent marked (and failed, with [defaults] over_budget = "fail")."""
929
938
  data = self._result(self.result_path(label, pack_name))
930
- return data["results"] if data else []
939
+ if not data:
940
+ return []
941
+ return client.mark_over_budget([dict(r) for r in data["results"]], sent_max_tokens(data),
942
+ self.cfg["defaults"]["over_budget"])
931
943
 
932
944
  def certify_repeat(self, pack):
933
945
  return pack.certify_repeat or self.cfg["defaults"]["repeat"]
@@ -1126,7 +1138,7 @@ class Engine:
1126
1138
 
1127
1139
  def _partial_header(self, job):
1128
1140
  return {"pack_fingerprint": job.pack.fingerprint, "settings_fingerprint": settings_fingerprint(job.settings),
1129
- "settings": job.settings}
1141
+ "settings": job.settings, "max_tokens": job.pack.max_tokens or job.sampling["max_tokens"]}
1130
1142
 
1131
1143
  def _header_ok(self, head, job):
1132
1144
  return header_matches(head, job.pack.fingerprint, job.settings)
@@ -278,7 +278,7 @@ def kind(r):
278
278
  return "right"
279
279
  if r.get("severity") == "critical":
280
280
  return "critical"
281
- if r["truncated"] or r["reason"].startswith("TRUNCATED"):
281
+ if r["truncated"] or r["reason"].startswith(("TRUNCATED", "OVER BUDGET")):
282
282
  return "cut off"
283
283
  if r["reason"].startswith(FORMAT):
284
284
  return "no answer / format"
@@ -78,6 +78,8 @@ def index(rows, models=None, memory=None):
78
78
  # failed only because max_tokens ran out (overthinking, not wrong yet)
79
79
  "cut": sum(c["truncated"] for c in cells),
80
80
  "cut_pct": 100 * sum(c["truncated"] for c in cells) / answers if answers else None,
81
+ # ran past the max_tokens sent: the provider didn't stop it, so it had more room than asked
82
+ "over": sum(c.get("over", 0) for c in cells),
81
83
  # generation speed and time to first token over the same answers (medians)
82
84
  "tps": compare.median(r.get("gen_tps") for c in cells for r in c["rows"]),
83
85
  "ttft": compare.median(r.get("ttft") for c in cells for r in c["rows"]),
@@ -147,6 +149,9 @@ def bars(result, width=20, markup=True, click=None):
147
149
  part = min(width - full, max(1, round(width * cut / 100))) if cut else 0
148
150
  if cut: # after the accuracy, which stays aligned with the other rows'
149
151
  num = f"{num:>6} ({compare.pct(cut / 100)} truncated)"
152
+ if x.get("over"):
153
+ note = f"({x['over']} over budget)"
154
+ num = (f"{num} " if cut else f"{num:>6} ") + (f"[yellow]{note}[/yellow]" if markup else note)
150
155
  else:
151
156
  full = _blocks(width, x.get(measure), most[measure])
152
157
  num = times(x.get(shown))
@@ -183,10 +188,13 @@ def left_out_lines(result):
183
188
  def table(result):
184
189
  """(header, rows) for a plain table of the index."""
185
190
  n = result["questions"] or 1
186
- header = ["model", "tokens/answer", "time/answer", "answers right", "truncated", "tok/s", "TTFT s", "memory GB"]
191
+ over = any(x.get("over") for x in result["models"]) # a column only when some answer ran past max_tokens
192
+ header = ["model", "tokens/answer", "time/answer", "answers right", "truncated", *(["over budget"] if over else []),
193
+ "tok/s", "TTFT s", "memory GB"]
187
194
  rows = [[x["model"], f"{round(x['tokens'] / n):,}" + (" ★" if x.get("fewest") else ""),
188
195
  fmt_secs(x["total_s"] / n) + (" ★" if x.get("fastest") else ""),
189
196
  f"{x['passed']}/{x['answers']}", f"{x.get('cut') or 0}/{x['answers']}",
197
+ *([f"{x.get('over') or 0}/{x['answers']}"] if over else []),
190
198
  _num(x.get("tps"), ".0f"), _num(x.get("ttft"), ".1f"),
191
199
  _num(x.get("memory_gb"), ".1f")] for x in result["models"]]
192
200
  return header, rows
@@ -222,7 +230,10 @@ def scope_note(result, models):
222
230
  "and how many times the leanest / fastest model's (1.0×, ★ in the table). A accuracy: answers right "
223
231
  "(longer is better)"
224
232
  + ("; orange, (n% truncated): answers cut off at max_tokens. They count as wrong, but the model ran "
225
- "out of tokens rather than answering wrong." if any(x.get("cut") for x in result["models"]) else "."))
233
+ "out of tokens rather than answering wrong." if any(x.get("cut") for x in result["models"]) else ".")
234
+ + (" Over budget: answers that used more tokens than the max_tokens sent, because the provider didn't "
235
+ "stop them, so the model had more room than the others (over_budget = \"fail\" in models.toml's defaults fails them)."
236
+ if any(x.get("over") for x in result["models"]) else ""))
226
237
 
227
238
 
228
239
  def for_engine(e, labels=None, packs=None, results_dir=None):
@@ -610,7 +610,9 @@ class AnswerScreen(ModalScreen):
610
610
  split = ", ".join(f"{k} {rec[k + '_tokens']:,}" for k in ("reasoning", "answer")
611
611
  if rec.get(k + "_tokens") is not None)
612
612
  facts = [f"finish {finish}",
613
- (f"{toks:,} tokens" + (f" ({split})" if split else "")) if toks else "",
613
+ (f"{toks:,} tokens" + (f" ({split})" if split else "")
614
+ + (f" [yellow]over the {rec['over_budget']:,} limit sent (the provider didn't stop it)[/yellow]"
615
+ if rec.get("over_budget") else "")) if toks else "",
614
616
  f"prompt {rec['prompt_tokens']:,} tokens" if rec.get("prompt_tokens") else "",
615
617
  (f"TTFT {rec['ttft_s']:.1f}s" + (f" [yellow]⟲ {rec['cached_tokens']} cached[/yellow]"
616
618
  if rec.get("cached_tokens") else "")) if rec.get("ttft_s") is not None else "",
@@ -3317,6 +3319,7 @@ class ResultsScreen(Screen):
3317
3319
  self.query_one("#pq-legend", Static).update(
3318
3320
  f"[dim]{len(tests)} questions. Each cell: result (passes/answers if repeated), median tokens, median seconds. "
3319
3321
  "[green]bold[/green] = fewest tokens among passing answers · [red]cut[/red] = hit max_tokens · "
3322
+ "[yellow]over[/yellow] = ran past max_tokens (the provider didn't stop it) · "
3320
3323
  "◐ = pack unfinished (not in scores) · dim seconds = hosted, or measured on another machine; tokens compare "
3321
3324
  "across machines, seconds don't. ×tokens = most / fewest tokens across models. Enter on a cell opens the "
3322
3325
  "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(
@@ -3376,7 +3379,8 @@ class ResultsScreen(Screen):
3376
3379
  "max"),
3377
3380
  hi(r["fewest"], str(r["fewest"]), "fewest", "max"),
3378
3381
  hi(r["fastest"], str(r["fastest"]), "fastest", "max"),
3379
- f"[red]{r['cut']}[/red]" if r["cut"] else "0"])
3382
+ (f"[red]{r['cut']}[/red]" if r["cut"] else "0")
3383
+ + (f" [yellow]+{r['over']} over[/yellow]" if r.get("over") else "")])
3380
3384
  table.add_columns(*header)
3381
3385
  table.add_rows(fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3))
3382
3386
  line = f"[b]Comparison[/b] over the {len(shared)} questions all {len(models)} answered"
@@ -3413,6 +3417,7 @@ class ResultsScreen(Screen):
3413
3417
  if remote:
3414
3418
  secs = f"[dim]{secs}[/dim]"
3415
3419
  return (f"{mark} {tok_s} {secs}" + (" [red]cut[/red]" if c["truncated"] else "")
3420
+ + (" [yellow]over[/yellow]" if c.get("over") else "")
3416
3421
  + (" [cyan]◐[/cyan]" if c["partial"] else ""))
3417
3422
 
3418
3423
  PQ_FIXED = ("Test", "Pack", "Level", "Added", "×tokens") # Per question's columns before the models
@@ -2421,6 +2421,81 @@ def fake_gguf(path, embedding=5120, size=0, kv_heads=4):
2421
2421
  f.write(out + b"\0" * max(0, size - len(out)))
2422
2422
 
2423
2423
 
2424
+ class OverBudget(unittest.TestCase):
2425
+ """Answers that used more tokens than the max_tokens sent (a provider that didn't stop them) are
2426
+ marked when results are read: flagged, or failed with [defaults] over_budget = "fail"."""
2427
+
2428
+ def setUp(self):
2429
+ from tuieval import packs
2430
+ self.tmp = tempfile.TemporaryDirectory()
2431
+ self.ws = os.path.join(self.tmp.name, "ws")
2432
+ tuieval(self.tmp.name, "init", self.ws)
2433
+ d = os.path.join(self.ws, "packs", "p")
2434
+ os.makedirs(d)
2435
+ write(os.path.join(d, "pack.toml"), 'label = "p"\nmax_tokens = 100\n[gate]\nmin_accuracy = 0.5\n')
2436
+ write(os.path.join(d, "tests.yaml"), "".join(f"- id: q{i}\n input: q{i}\n expected: 7\n" for i in range(4)))
2437
+ rec = lambda i, ok, toks, **kw: {"test": f"q{i}", "description": f"q{i}", "pass": ok, "score": float(ok),
2438
+ "reason": "ok" if ok else "wrong", "finish": "stop", "repeat": 0,
2439
+ "completion_tokens": toks, **kw}
2440
+ self.path = os.path.join(self.ws, "results", "m", "p.json")
2441
+ os.makedirs(os.path.dirname(self.path))
2442
+ with open(self.path, "w") as f:
2443
+ json.dump({"format": "evals/1", "model": {"label": "m"},
2444
+ "pack": {"name": "p", "fingerprint": packs.load_pack(d).fingerprint, "max_tokens": 100},
2445
+ "run": {}, "settings": {"max_tokens": 16384},
2446
+ "results": [rec(0, True, 150), # past the pack's 100: over
2447
+ rec(1, True, 90),
2448
+ rec(2, False, 100, finish="length"), # cut off: truncated, not over
2449
+ rec(3, True, 500, tokens_estimated=True)]}, f) # an estimate: never flagged
2450
+
2451
+ def tearDown(self):
2452
+ self.tmp.cleanup()
2453
+
2454
+ def set_mode(self, mode):
2455
+ path = os.path.join(self.ws, "models.toml")
2456
+ write(path, read(path).replace("[defaults]\n", f'[defaults]\nover_budget = "{mode}"\n', 1))
2457
+
2458
+ def test_flag_marks_copies_and_shows_counts(self):
2459
+ from tuieval import compare, engine, pta
2460
+ e = engine.Engine(root=self.ws)
2461
+ recs = e.records("m", "p")
2462
+ self.assertEqual([r.get("over_budget") for r in recs], [100, None, None, None])
2463
+ self.assertEqual([r["pass"] for r in recs], [True, True, False, True])
2464
+ self.assertNotIn("over_budget", e._result(self.path)["results"][0]) # what a rerun writes back stays as stored
2465
+ self.assertNotIn("over_budget", read(self.path))
2466
+ rows, _ = compare.load([self.path])
2467
+ self.assertEqual([r["over_budget"] for r in rows], [100, None, None, None])
2468
+ cell = compare.per_question(rows)[0]["cells"]["m"]
2469
+ self.assertEqual((cell["over"], cell["passed"]), (1, 1))
2470
+ header, table = pta.table(pta.index(rows))
2471
+ self.assertEqual(table[0][header.index("over budget")], "1/4")
2472
+ self.assertIn("(1 over budget)", "\n".join(pta.bars(pta.index(rows), markup=False)))
2473
+ self.assertIn("over budget", compare.scorecard(rows)[0])
2474
+
2475
+ def test_fail_mode_fails_them(self):
2476
+ from tuieval import compare, engine
2477
+ self.set_mode("fail") # records() is what verdicts are computed from
2478
+ e = engine.Engine(root=self.ws)
2479
+ recs = e.records("m", "p")
2480
+ self.assertEqual([r["pass"] for r in recs], [False, True, False, True])
2481
+ self.assertTrue(recs[0]["reason"].startswith("OVER BUDGET: 150 tokens, over the 100 limit sent; ok"))
2482
+ rows, _ = compare.load([self.path])
2483
+ self.assertEqual([r["ok"] for r in rows], [False, True, False, True])
2484
+ self.assertIn('"pass": true', read(self.path)) # the stored grade is unchanged
2485
+
2486
+ def test_partials_and_bad_setting(self):
2487
+ from tuieval import compare, engine
2488
+ part = os.path.join(self.ws, "results", "m", "q.json.partial.jsonl")
2489
+ with open(part, "w") as f:
2490
+ f.write(json.dumps({"pack_fingerprint": "x", "max_tokens": 50, "settings": {"sampling": {"max_tokens": 999}}}) + "\n")
2491
+ f.write(json.dumps({"test": "a", "description": "a", "pass": True, "score": 1.0, "reason": "ok",
2492
+ "finish": "stop", "completion_tokens": 60}) + "\n")
2493
+ self.assertEqual([r["over_budget"] for r in compare.load_partials(os.path.join(self.ws, "results"))], [50])
2494
+ self.set_mode("sometimes")
2495
+ with self.assertRaisesRegex(engine.ConfigError, "over_budget must be one of"):
2496
+ engine.Engine(root=self.ws)
2497
+
2498
+
2424
2499
  class FitCheck(unittest.TestCase):
2425
2500
  def test_default_follows_the_ctx_placeholder(self):
2426
2501
  from tuieval import engine
File without changes
File without changes