tuieval 0.2.0.dev68__tar.gz → 0.2.0.dev70__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/PKG-INFO +3 -1
  3. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/README.md +2 -0
  4. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/cli.py +4 -0
  6. tuieval-0.2.0.dev70/src/tuieval/logs.py +220 -0
  7. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/tests/test_tuieval.py +83 -0
  8. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/RELEASING.md +0 -0
  11. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/README.md +0 -0
  12. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/favicon.ico +0 -0
  13. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/readme-header.png +0 -0
  14. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/social-preview.png +0 -0
  15. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-1024.png +0 -0
  16. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-128.png +0 -0
  17. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-16.png +0 -0
  18. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-16.svg +0 -0
  19. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-256.png +0 -0
  20. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-32.png +0 -0
  21. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-48.png +0 -0
  22. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-512.png +0 -0
  23. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-64.png +0 -0
  24. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  25. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/brand/tuieval-icon.svg +0 -0
  26. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/tui-answer-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/tui-run-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/images/tui-setup.png +0 -0
  29. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/models.md +0 -0
  30. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/docs/writing-packs.md +0 -0
  31. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/pyproject.toml +0 -0
  32. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/__init__.py +0 -0
  33. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/__main__.py +0 -0
  34. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/builtin.toml +0 -0
  35. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/client.py +0 -0
  36. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/compare.py +0 -0
  37. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/doctor.py +0 -0
  38. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/engine.py +0 -0
  39. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/machines.py +0 -0
  47. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/onboard.py +0 -0
  48. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/packs.py +0 -0
  49. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/profiles.py +0 -0
  50. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/pta.py +0 -0
  51. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/remove.py +0 -0
  52. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/run_evals.py +0 -0
  53. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/scaffold.py +0 -0
  54. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/selftest.py +0 -0
  55. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/models.toml +0 -0
  56. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  57. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/answer/system.txt +0 -0
  58. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  59. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/code/pack.toml +0 -0
  60. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/code/system.txt +0 -0
  61. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  62. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  63. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/rag/system.txt +0 -0
  64. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  66. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/reply/system.txt +0 -0
  67. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  69. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  70. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  71. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  72. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/tui.py +0 -0
  73. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/tune.py +0 -0
  74. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/verdict.py +0 -0
  75. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev68 → tuieval-0.2.0.dev70}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `tuieval logs` follows the server log of the model running now and moves on with the run; `logs live` shows each answer as it finishes, `--list` every log.
5
6
  - Results tables sort by a click on a column name; a second click reverses. Numbers, times and levels sort by value; empty cells stay last.
6
7
  - Nvidia and AMD GPUs on Linux: VRAM from `nvidia-smi`, `amd-smi` or `rocm-smi` sizes models (`gpu_memory_gb` overrides it), runs record peak VRAM, and `tuieval doctor` shows the GPU.
7
8
  - Results has four tabs: PTA index, Production readiness, Per question and Failures. The PTA table adds tok/s, TTFT and memory; Per question can show only where models disagree; the settings warning is one line. Scorecard, pairwise, history and test quality stay in `tuieval compare`, `history` and `items`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev68
3
+ Version: 0.2.0.dev70
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -162,6 +162,8 @@ tuieval history # every verdict change over time
162
162
  tuieval compare --speed --pairwise --failures # scorecards
163
163
  tuieval selftest # check every test's reference and wrong answers
164
164
  tuieval items # tests that don't separate models or look broken
165
+ tuieval logs # follow the server log of the model running now
166
+ tuieval logs live # each answer as it finishes (--list: every log)
165
167
  tuieval capture logs/live/<file> --pack X # turn a real failure into a new test
166
168
  tuieval regrade # re-score stored answers after changing a grader
167
169
  tuieval regrade --only <model> --packs X # just one model's results (and packs)
@@ -136,6 +136,8 @@ tuieval history # every verdict change over time
136
136
  tuieval compare --speed --pairwise --failures # scorecards
137
137
  tuieval selftest # check every test's reference and wrong answers
138
138
  tuieval items # tests that don't separate models or look broken
139
+ tuieval logs # follow the server log of the model running now
140
+ tuieval logs live # each answer as it finishes (--list: every log)
139
141
  tuieval capture logs/live/<file> --pack X # turn a real failure into a new test
140
142
  tuieval regrade # re-score stored answers after changing a grader
141
143
  tuieval regrade --only <model> --packs X # just one model's results (and packs)
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0.dev68'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev68')
21
+ __version__ = version = '0.2.0.dev70'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev70')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -18,6 +18,7 @@ Day to day
18
18
  tuieval report the verdicts with their evidence, as a markdown file
19
19
  tuieval history every verdict change, newest first
20
20
  tuieval items tests that don't separate models or look broken
21
+ tuieval logs follow the server log of the model running now (live: its answers)
21
22
  tuieval capture LOG --pack turn a real failure (logs/live/*.txt) into a new test
22
23
  tuieval regrade re-score stored answers after changing a grader (--only, --packs)
23
24
  tuieval list the models tuieval knows, hidden ones separately (--packs: the packs)
@@ -85,6 +86,9 @@ def main(argv=None):
85
86
  if cmd == "watch":
86
87
  from . import watch_proxy
87
88
  return watch_proxy.main(rest)
89
+ if cmd == "logs":
90
+ from . import logs
91
+ return logs.main(rest)
88
92
  from . import run_evals
89
93
  if cmd in run_evals.COMMANDS:
90
94
  return run_evals.COMMANDS[cmd](rest)
@@ -0,0 +1,220 @@
1
+ """tuieval logs: follow the server log of the model running now, without naming it.
2
+
3
+ tuieval logs the newest server log, then every log that grows (a header says
4
+ which model each part is from), so it moves on with the run
5
+ tuieval logs live each answer (reasoning and answer, from logs/live/) as it finishes
6
+ tuieval logs --list every model's server log, newest first
7
+ tuieval logs MODEL one model's log (any unambiguous part of its label)
8
+
9
+ Read-only: it never touches servers or results. Only servers tuieval starts write a log here;
10
+ OpenRouter and servers that were already running don't.
11
+ """
12
+ import argparse
13
+ import datetime
14
+ import os
15
+ import re
16
+ import sys
17
+ import time
18
+
19
+ from . import workspace
20
+
21
+ POLL_S = 0.5
22
+
23
+
24
+ def server_dir():
25
+ return workspace.path("logs", "server")
26
+
27
+
28
+ def live_dir():
29
+ return workspace.path("logs", "live")
30
+
31
+
32
+ def _files(d, ext):
33
+ """{path: (size, mtime)} for the files in d with this extension (none if d doesn't exist)."""
34
+ out = {}
35
+ try:
36
+ names = os.listdir(d)
37
+ except OSError:
38
+ return out
39
+ for n in names:
40
+ if n.endswith(ext):
41
+ p = os.path.join(d, n)
42
+ try:
43
+ st = os.stat(p)
44
+ except OSError:
45
+ continue
46
+ out[p] = (st.st_size, st.st_mtime)
47
+ return out
48
+
49
+
50
+ def _label(path):
51
+ return os.path.basename(path)[:-len(".log")]
52
+
53
+
54
+ def _tail_offset(path, lines):
55
+ """Where the last `lines` lines of a file start."""
56
+ with open(path, "rb") as f:
57
+ f.seek(0, os.SEEK_END)
58
+ end = pos = f.tell()
59
+ found, chunk = 0, 8192
60
+ while pos > 0 and found <= lines:
61
+ pos = max(0, pos - chunk)
62
+ f.seek(pos)
63
+ found = f.read(end - pos).count(b"\n")
64
+ if pos == 0 and found <= lines:
65
+ return 0
66
+ f.seek(pos)
67
+ data = f.read(end - pos)
68
+ cut = len(data)
69
+ for _ in range(lines + 1):
70
+ cut = data.rfind(b"\n", 0, cut)
71
+ return pos + cut + 1
72
+
73
+
74
+ class Follower:
75
+ """Follows server logs. `poll()` returns the text written since the last poll, with a
76
+ `── label ──` header whenever it comes from another log than the text before it.
77
+ only: follow just this log. Otherwise every log in the folder, including new ones."""
78
+
79
+ def __init__(self, folder, lines=40, only=None):
80
+ self.folder, self.only = folder, only
81
+ self.offsets = {p: size for p, (size, _) in _files(folder, ".log").items()}
82
+ self.last = None # the log the previous text came from
83
+ self.held = {} # path -> size at the last poll, for a line still being written
84
+ start = only or max(self.offsets, key=lambda p: os.path.getmtime(p), default=None)
85
+ if start and start in self.offsets:
86
+ self.offsets[start] = _tail_offset(start, lines)
87
+
88
+ def poll(self):
89
+ out = []
90
+ files = _files(self.folder, ".log")
91
+ if self.only:
92
+ files = {p: v for p, v in files.items() if p == self.only}
93
+ for path, (size, _) in sorted(files.items(), key=lambda kv: kv[1][1]): # oldest change first
94
+ off = self.offsets.setdefault(path, 0)
95
+ if size < off: # truncated or replaced: read it from the start
96
+ off = 0
97
+ if size == off:
98
+ continue
99
+ with open(path, "rb") as f:
100
+ f.seek(off)
101
+ data = f.read(size - off)
102
+ cut = data.rfind(b"\n") + 1
103
+ if not cut: # no complete line yet: wait one poll for the rest of it
104
+ if self.held.get(path) != size:
105
+ self.held[path] = size
106
+ continue
107
+ cut = len(data)
108
+ self.held.pop(path, None)
109
+ self.offsets[path] = off + cut
110
+ if path != self.last:
111
+ out.append(f"── {_label(path)} ──\n")
112
+ self.last = path
113
+ out.append(data[:cut].decode("utf-8", "replace"))
114
+ return "".join(out)
115
+
116
+
117
+ FOOTER = re.compile(r"^=== .*finish=.* ===$", re.M)
118
+
119
+
120
+ class LiveFollower:
121
+ """New answers in logs/live/ as they're written: each one from its reasoning on (the prompt is
122
+ in the file), under a header with the file name."""
123
+
124
+ def __init__(self, folder, last=1):
125
+ self.folder = folder
126
+ files = sorted(_files(folder, ".txt").items(), key=lambda kv: kv[1][1])
127
+ self.seen = {p for p, _ in files[:max(0, len(files) - last)]}
128
+
129
+ def poll(self):
130
+ out = []
131
+ for path, (_, mtime) in sorted(_files(self.folder, ".txt").items(), key=lambda kv: kv[1][1]):
132
+ if path in self.seen:
133
+ continue
134
+ try:
135
+ with open(path, errors="replace") as f:
136
+ text = f.read()
137
+ except OSError:
138
+ continue
139
+ if not FOOTER.search(text) and time.time() - mtime < 2: # still being written
140
+ continue
141
+ self.seen.add(path)
142
+ i = text.find("=== REASONING ===")
143
+ out.append(f"── {os.path.basename(path)[:-4]} ──\n{text[i:] if i >= 0 else text}\n")
144
+ return "".join(out)
145
+
146
+
147
+ def _ago(mtime):
148
+ s = max(0, int(time.time() - mtime))
149
+ if s < 60:
150
+ return f"{s}s ago"
151
+ if s < 3600:
152
+ return f"{s // 60} min ago"
153
+ if s < 86400:
154
+ return f"{s // 3600} h ago"
155
+ return datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
156
+
157
+
158
+ def _size(n):
159
+ for unit in ("B", "KB", "MB", "GB"):
160
+ if n < 1024 or unit == "GB":
161
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
162
+ n /= 1024
163
+
164
+
165
+ def list_logs(folder):
166
+ files = sorted(_files(folder, ".log").items(), key=lambda kv: -kv[1][1])
167
+ if not files:
168
+ return f"No server logs yet in {os.path.relpath(folder)}/."
169
+ w = max(len(_label(p)) for p, _ in files)
170
+ return "\n".join(f"{_label(p):<{w}} {_size(size):>9} {_ago(mtime)}" for p, (size, mtime) in files)
171
+
172
+
173
+ def find_log(folder, name):
174
+ """The log whose label is `name`, or the only one containing it. Raises SystemExit otherwise."""
175
+ labels = {_label(p): p for p in _files(folder, ".log")}
176
+ if name in labels:
177
+ return labels[name]
178
+ hits = sorted(l for l in labels if name.lower() in l.lower())
179
+ if len(hits) == 1:
180
+ return labels[hits[0]]
181
+ if not hits:
182
+ sys.exit(f"No server log matches {name!r}; tuieval logs --list shows them.")
183
+ sys.exit(f"{name!r} matches {len(hits)} logs; say which:\n " + "\n ".join(hits))
184
+
185
+
186
+ def follow(follower, waiting):
187
+ """Print what the follower finds until ctrl-c."""
188
+ said = False
189
+ try:
190
+ while True:
191
+ text = follower.poll()
192
+ if text:
193
+ sys.stdout.write(text)
194
+ sys.stdout.flush()
195
+ said = True
196
+ elif not said:
197
+ print(waiting, flush=True)
198
+ said = True
199
+ time.sleep(POLL_S)
200
+ except KeyboardInterrupt:
201
+ return 0
202
+
203
+
204
+ def main(argv):
205
+ p = argparse.ArgumentParser(prog="tuieval logs", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter,
206
+ epilog="ctrl-c stops following (a run going on isn't affected).")
207
+ p.add_argument("what", nargs="?", help="'live' for answers, or a model (any unambiguous part of its label)")
208
+ p.add_argument("--list", action="store_true", help="every model's server log, newest first")
209
+ p.add_argument("-n", "--lines", type=int, default=40, help="lines shown before following (default 40)")
210
+ a = p.parse_args(argv)
211
+ if a.list:
212
+ print(list_logs(server_dir()))
213
+ return 0
214
+ if a.what == "live":
215
+ return follow(LiveFollower(live_dir()),
216
+ f"Waiting for answers in {os.path.relpath(live_dir())}/ (ctrl-c stops)")
217
+ only = find_log(server_dir(), a.what) if a.what else None
218
+ return follow(Follower(server_dir(), a.lines, only),
219
+ f"Waiting for {'that model' if only else 'a server'} to log in "
220
+ f"{os.path.relpath(server_dir())}/ (ctrl-c stops)")
@@ -1705,5 +1705,88 @@ class Remove(unittest.TestCase):
1705
1705
  self.assertTrue(os.path.exists(os.path.join(ws, "removed", kept, rel)), rel)
1706
1706
 
1707
1707
 
1708
+
1709
+ class Logs(unittest.TestCase):
1710
+ """tuieval logs: follows whichever model's server log is being written, without naming it."""
1711
+
1712
+ def test_follows_the_log_being_written(self):
1713
+ from tuieval import logs
1714
+ with tempfile.TemporaryDirectory() as d:
1715
+ a, b = os.path.join(d, "model-a.log"), os.path.join(d, "model-b.log")
1716
+ write(a, "a old\n")
1717
+ write(b, "".join(f"b {i}\n" for i in range(50)))
1718
+ os.utime(a, (time.time() - 60,) * 2)
1719
+ f = logs.Follower(d, lines=3)
1720
+ self.assertEqual(f.poll(), "── model-b ──\nb 47\nb 48\nb 49\n") # the newest, its last lines
1721
+ self.assertEqual(f.poll(), "")
1722
+ with open(a, "a") as fh:
1723
+ fh.write("a new\n") # the run moved on to model a
1724
+ self.assertEqual(f.poll(), "── model-a ──\na new\n")
1725
+ with open(b, "a") as fh:
1726
+ fh.write("half a li") # a line still being written
1727
+ self.assertEqual(f.poll(), "")
1728
+ self.assertEqual(f.poll(), "── model-b ──\nhalf a li") # unchanged since: shown anyway
1729
+ write(os.path.join(d, "model-c.log"), "c starts\n") # a model that never ran before
1730
+ self.assertEqual(f.poll(), "── model-c ──\nc starts\n")
1731
+ write(a, "fresh\n") # replaced: read from the start
1732
+ self.assertEqual(f.poll(), "── model-a ──\nfresh\n")
1733
+ only = logs.Follower(d, lines=1, only=b)
1734
+ self.assertEqual(only.poll(), "── model-b ──\nb 49\n") # complete lines first
1735
+ self.assertEqual((only.poll(), only.poll()), ("", "half a li")) # held one poll, as above
1736
+ with open(a, "a") as fh:
1737
+ fh.write("ignored\n")
1738
+ self.assertEqual(only.poll(), "")
1739
+
1740
+ def test_live_answers(self):
1741
+ from tuieval import logs
1742
+ with tempfile.TemporaryDirectory() as d:
1743
+ def answer(name, done=True, age=0):
1744
+ path = os.path.join(d, name + ".txt")
1745
+ write(path, "MODEL: m\nPROMPT: the question\n\n=== REASONING ===\nthink\n\n=== ANSWER ===\n42\n"
1746
+ + ("\n=== 10 tokens, 1.0s, finish=stop ===\n" if done else ""))
1747
+ os.utime(path, (time.time() - age,) * 2)
1748
+ answer("20260101_100000_m_pack_old", age=20)
1749
+ answer("20260101_100100_m_pack_last", age=10)
1750
+ f = logs.LiveFollower(d)
1751
+ first = f.poll()
1752
+ self.assertIn("── 20260101_100100_m_pack_last ──\n=== REASONING ===", first) # the latest one only
1753
+ self.assertNotIn("_old", first)
1754
+ self.assertNotIn("PROMPT", first)
1755
+ answer("20260101_100200_m_pack_new", done=False) # still being written
1756
+ self.assertEqual(f.poll(), "")
1757
+ answer("20260101_100200_m_pack_new")
1758
+ self.assertIn("=== ANSWER ===\n42", f.poll())
1759
+
1760
+ def test_cli(self):
1761
+ with tempfile.TemporaryDirectory() as tmp:
1762
+ ws = os.path.join(tmp, "ws")
1763
+ tuieval(tmp, "init", ws)
1764
+ self.assertIn("No server logs yet", tuieval(ws, "logs", "--list").stdout)
1765
+ d = os.path.join(ws, "logs", "server")
1766
+ os.makedirs(d, exist_ok=True)
1767
+ for i, name in enumerate(("coder-q4", "coder-q5", "chat")):
1768
+ write(os.path.join(d, name + ".log"), f"{name} line\n")
1769
+ os.utime(os.path.join(d, name + ".log"), (time.time() - 100 + i,) * 2)
1770
+ listed = tuieval(ws, "logs", "--list").stdout.splitlines()
1771
+ self.assertEqual([l.split()[0] for l in listed], ["chat", "coder-q5", "coder-q4"]) # newest first
1772
+ p = tuieval(ws, "logs", "coder", check=False)
1773
+ self.assertNotEqual(p.returncode, 0)
1774
+ self.assertIn("matches 2 logs", p.stderr)
1775
+ self.assertIn("No server log matches", tuieval(ws, "logs", "nothing", check=False).stderr)
1776
+ # following: the newest log, then whichever one grows; ctrl-c stops it cleanly
1777
+ proc = subprocess.Popen([sys.executable, "-m", "tuieval", "logs", "-n", "1"], stdout=subprocess.PIPE,
1778
+ text=True, env=dict(os.environ, TUIEVAL_HOME=ws))
1779
+ try:
1780
+ time.sleep(1.5)
1781
+ with open(os.path.join(d, "coder-q4.log"), "a") as fh:
1782
+ fh.write("q4 server started\n")
1783
+ time.sleep(1.5)
1784
+ finally:
1785
+ proc.send_signal(__import__("signal").SIGINT)
1786
+ out, _ = proc.communicate(timeout=20)
1787
+ self.assertEqual(proc.returncode, 0)
1788
+ self.assertEqual(out, "── chat ──\nchat line\n── coder-q4 ──\nq4 server started\n")
1789
+
1790
+
1708
1791
  if __name__ == "__main__":
1709
1792
  unittest.main()
File without changes
File without changes