tuieval 0.2.0.dev133__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.
Files changed (82) hide show
  1. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/models.md +6 -0
  4. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/builtin.toml +2 -0
  6. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/client.py +36 -1
  7. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/compare.py +3 -2
  8. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/engine.py +7 -4
  9. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/graders/__init__.py +4 -0
  10. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/insights.py +1 -1
  11. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/tui.py +2 -1
  12. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/tests/mock_server.py +6 -2
  13. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/tests/test_tuieval.py +42 -2
  14. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/.gitignore +0 -0
  15. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/LICENSE +0 -0
  16. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/README.md +0 -0
  17. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/RELEASING.md +0 -0
  18. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/agent-packs.md +0 -0
  19. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/README.md +0 -0
  20. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/favicon.ico +0 -0
  21. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/readme-header.png +0 -0
  22. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/social-preview.png +0 -0
  23. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-1024.png +0 -0
  24. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-128.png +0 -0
  25. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-16.png +0 -0
  26. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-16.svg +0 -0
  27. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-256.png +0 -0
  28. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-32.png +0 -0
  29. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-48.png +0 -0
  30. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-512.png +0 -0
  31. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-64.png +0 -0
  32. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  33. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/brand/tuieval-icon.svg +0 -0
  34. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/tui-answer-reasoning.png +0 -0
  35. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/tui-run-reasoning.png +0 -0
  36. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/images/tui-setup.png +0 -0
  37. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/docs/writing-packs.md +0 -0
  38. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/pyproject.toml +0 -0
  39. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/__init__.py +0 -0
  40. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/__main__.py +0 -0
  41. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/agents.py +0 -0
  42. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/cli.py +0 -0
  43. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/dates.py +0 -0
  44. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/doctor.py +0 -0
  45. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/export.py +0 -0
  46. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/graders/answer.py +0 -0
  47. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/graders/code.py +0 -0
  48. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/graders/rag.py +0 -0
  49. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/graders/reply.py +0 -0
  50. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/graders/tool_call.py +0 -0
  51. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/logs.py +0 -0
  52. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/machines.py +0 -0
  53. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/onboard.py +0 -0
  54. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/packs.py +0 -0
  55. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/profiles.py +0 -0
  56. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/pta.py +0 -0
  57. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/run_evals.py +0 -0
  59. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/scaffold.py +0 -0
  60. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/selftest.py +0 -0
  61. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/models.toml +0 -0
  62. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  63. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/answer/system.txt +0 -0
  64. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/code/pack.toml +0 -0
  66. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/code/system.txt +0 -0
  67. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  69. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/rag/system.txt +0 -0
  70. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  71. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  72. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/reply/system.txt +0 -0
  73. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  74. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  75. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  76. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  77. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  78. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/tune.py +0 -0
  79. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/verdict.py +0 -0
  80. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/watch_proxy.py +0 -0
  81. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/workspace.py +0 -0
  82. {tuieval-0.2.0.dev133 → tuieval-0.2.0.dev135}/src/tuieval/yamlout.py +0 -0
@@ -2,6 +2,7 @@
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.
5
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.
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev133
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
@@ -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.dev133'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev133')
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"),
@@ -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
- {"finish": res["finish"], "tool_calls": res["tool_calls"]})
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"
@@ -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" else finish
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)
@@ -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, port=self.port)
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"))
@@ -3188,6 +3202,32 @@ class StallGuard(unittest.TestCase):
3188
3202
  self.assertTrue(client.is_server_error(res["error"]), res["error"])
3189
3203
  self.assertIsNone(res["finish"])
3190
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
+
3191
3231
  def test_finish_without_done_is_an_answer(self):
3192
3232
  res, _ = self.ask("--die-after", "99", timeout=30) # every chunk, then gone before [DONE]
3193
3233
  self.assertIsNone(res["error"])
File without changes
File without changes