tuieval 0.2.0.dev66__tar.gz → 0.2.0.dev68__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 (77) hide show
  1. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/PKG-INFO +2 -2
  3. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/README.md +1 -1
  4. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/tui.py +130 -7
  6. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/tests/test_tuieval.py +46 -1
  7. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/.gitignore +0 -0
  8. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/LICENSE +0 -0
  9. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/RELEASING.md +0 -0
  10. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/README.md +0 -0
  11. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/favicon.ico +0 -0
  12. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/readme-header.png +0 -0
  13. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/social-preview.png +0 -0
  14. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-1024.png +0 -0
  15. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-128.png +0 -0
  16. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-16.png +0 -0
  17. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-16.svg +0 -0
  18. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-256.png +0 -0
  19. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-32.png +0 -0
  20. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-48.png +0 -0
  21. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-512.png +0 -0
  22. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-64.png +0 -0
  23. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  24. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/brand/tuieval-icon.svg +0 -0
  25. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/tui-answer-reasoning.png +0 -0
  26. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/tui-run-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/images/tui-setup.png +0 -0
  28. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/models.md +0 -0
  29. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/docs/writing-packs.md +0 -0
  30. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/pyproject.toml +0 -0
  31. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/__init__.py +0 -0
  32. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/__main__.py +0 -0
  33. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/builtin.toml +0 -0
  34. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/cli.py +0 -0
  35. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/client.py +0 -0
  36. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/compare.py +0 -0
  37. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/doctor.py +0 -0
  38. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/engine.py +0 -0
  39. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/machines.py +0 -0
  47. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/onboard.py +0 -0
  48. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/packs.py +0 -0
  49. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/profiles.py +0 -0
  50. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/pta.py +0 -0
  51. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/remove.py +0 -0
  52. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/run_evals.py +0 -0
  53. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/scaffold.py +0 -0
  54. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/selftest.py +0 -0
  55. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/models.toml +0 -0
  56. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  57. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/answer/system.txt +0 -0
  58. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  59. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/code/pack.toml +0 -0
  60. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/code/system.txt +0 -0
  61. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  62. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  63. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/rag/system.txt +0 -0
  64. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  66. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/reply/system.txt +0 -0
  67. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  69. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  70. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  71. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  72. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/tune.py +0 -0
  73. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/verdict.py +0 -0
  74. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/watch_proxy.py +0 -0
  75. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/workspace.py +0 -0
  76. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/src/tuieval/yamlout.py +0 -0
  77. {tuieval-0.2.0.dev66 → tuieval-0.2.0.dev68}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - 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.
5
6
  - 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.
6
7
  - 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`.
7
8
  - The run's ETA adds up each pack's answers left × that model's time per answer there (its history, then its own times once it has 5 answers), instead of extrapolating from the first few answers.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev66
3
+ Version: 0.2.0.dev68
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
@@ -144,7 +144,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
144
144
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
145
145
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
146
146
  3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
147
- 4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
147
+ 4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures; click a column name to sort. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
148
148
 
149
149
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
150
150
 
@@ -118,7 +118,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
118
118
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
119
119
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
120
120
  3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
121
- 4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
121
+ 4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures; click a column name to sort. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
122
122
 
123
123
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
124
124
 
@@ -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.dev66'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev66')
21
+ __version__ = version = '0.2.0.dev68'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev68')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -39,6 +39,8 @@ from textual.widgets.option_list import Option
39
39
  from textual.widgets.selection_list import Selection
40
40
 
41
41
  import yaml
42
+ from rich.cells import cell_len
43
+ from rich.text import Text
42
44
 
43
45
  from . import compare
44
46
  from . import engine
@@ -181,6 +183,79 @@ def fmt_secs(secs):
181
183
  return f"{secs // 60}m{secs % 60:02d}s" if secs >= 60 else f"{secs}s"
182
184
 
183
185
 
186
+ # -- sorting Results tables by a click on a column name
187
+ SORT_ARROWS = (" ▲", " ▼")
188
+ SORT_RANKS = {"EASY": 1, "MEDIUM": 2, "HARD": 3, "FAIL": 1, "INCONCLUSIVE": 2, "PASS": 3,
189
+ "DOESN'T FIT": float("inf")}
190
+
191
+
192
+ class SortCell(Text):
193
+ """A table cell that sorts by its `sort_value` instead of what it shows."""
194
+
195
+
196
+ def sort_cell(markup, value):
197
+ t = Text.from_markup(markup)
198
+ cell = SortCell(t.plain, spans=t.spans)
199
+ cell.sort_value = value
200
+ return cell
201
+
202
+
203
+ def _sort_number(tok):
204
+ """1,234 · 3.1k · 2.4× · 12/20 · 4m10s · 1h02m · 12.5s as a number, else None."""
205
+ tok = tok.rstrip("★×%").replace(",", "")
206
+ if m := re.fullmatch(r"(\d+)/(\d+)", tok): # answers right: the share, then the count
207
+ a, b = int(m[1]), int(m[2])
208
+ return (a / b if b else 0) + a * 1e-9
209
+ if (m := re.fullmatch(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?", tok)) and any(m.groups()):
210
+ h, mi, s = m.groups()
211
+ return int(h or 0) * 3600 + int(mi or 0) * 60 + float(s or 0)
212
+ if m := re.fullmatch(r"(-?\d+(?:\.\d+)?)(k?)", tok):
213
+ return float(m[1]) * (1000 if m[2] else 1)
214
+ return None
215
+
216
+
217
+ def sort_value(cell):
218
+ """What a Results cell sorts by: the number it shows, a rank for levels and verdicts, else its
219
+ text; None (always last) for an empty cell."""
220
+ if isinstance(cell, SortCell):
221
+ return cell.sort_value
222
+ plain = (cell.plain if isinstance(cell, Text) else Text.from_markup(str(cell)).plain).strip()
223
+ if plain.lower() in ("", "-", "no data", "unrated"):
224
+ return None
225
+ if plain.upper() in SORT_RANKS:
226
+ return SORT_RANKS[plain.upper()]
227
+ first = re.sub(r"^(OK|TOO SLOW) ", "", plain).split()[0] # latency: by its p90 seconds
228
+ n = _sort_number(first)
229
+ return plain.casefold() if n is None else n
230
+
231
+
232
+ class _Desc:
233
+ """Reverses the order of what it wraps (so empty cells stay last when sorting descending)."""
234
+ __slots__ = ("v",)
235
+
236
+ def __init__(self, v):
237
+ self.v = v
238
+
239
+ def __eq__(self, other):
240
+ return self.v == other.v
241
+
242
+ def __lt__(self, other):
243
+ return other.v < self.v
244
+
245
+
246
+ def sort_key(cell, reverse=False):
247
+ v = sort_value(cell)
248
+ if v is None:
249
+ return (1,)
250
+ return (0, isinstance(v, str), _Desc(v) if reverse else v)
251
+
252
+
253
+ def column_name(label):
254
+ """A column's label without the sort arrow."""
255
+ text = label.plain if isinstance(label, Text) else str(label)
256
+ return next((text[:-len(a)] for a in SORT_ARROWS if text.endswith(a)), text)
257
+
258
+
184
259
  class StreamView(TextArea):
185
260
  """Read-only, word-wrapped text that grows as tokens stream in. Scroll it with the mouse; it follows
186
261
  new text only while scrolled to the bottom."""
@@ -340,6 +415,8 @@ with the model's reasoning and answer as they stream, then recent results with t
340
415
  Per question every question, model by model (filters: pack, level, Models disagree)
341
416
  Failures every wrong answer with the grader's reason; enter opens it in full
342
417
 
418
+ Click a column name to sort a table by it; click it again to reverse. Empty cells stay last.
419
+
343
420
  More in the terminal: tuieval compare (--pairwise: is a difference real), tuieval history,
344
421
  tuieval items (test quality).
345
422
 
@@ -2408,7 +2485,7 @@ class ResultsScreen(Screen):
2408
2485
  yield DataTable(id="pq-summary", zebra_stripes=True, cursor_type="none")
2409
2486
  yield Static(id="pq-legend")
2410
2487
  yield DataTable(id="per-question", zebra_stripes=True, cursor_type="cell")
2411
- with TabPane("Failures"):
2488
+ with TabPane("Failures", id="tab-failures"):
2412
2489
  yield Static("[dim]Enter on a row shows the whole answer, its reasoning and what was checked.[/dim]")
2413
2490
  yield DataTable(id="failures", zebra_stripes=True, cursor_type="row")
2414
2491
  yield Footer()
@@ -2417,6 +2494,8 @@ class ResultsScreen(Screen):
2417
2494
  self.rows, self.infos, self.partial_rows, self.superseded = [], [], [], set()
2418
2495
  if not hasattr(self.app, "pq_models"):
2419
2496
  self.app.pq_models = set()
2497
+ if not hasattr(self.app, "result_sorts"):
2498
+ self.app.result_sorts = {} # table id -> (column name, descending), kept for this session
2420
2499
  self.fill()
2421
2500
 
2422
2501
  def on_screen_resume(self):
@@ -2488,15 +2567,17 @@ class ResultsScreen(Screen):
2488
2567
  f.add_columns("Model", "Level", "Test", "Reason")
2489
2568
  # compare.failures lists the failed rows in order, so they line up with fail_rows
2490
2569
  self.fail_rows = [r for r in rows if not r["ok"]]
2491
- for model, test, reason, level in compare.failures(rows):
2492
- f.add_row(model, diff_badge(level), test, rich_escape(reason[:130]))
2570
+ for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
2571
+ f.add_row(model, diff_badge(level), test, rich_escape(reason[:130]), key=str(i))
2493
2572
  self.fill_per_question()
2494
2573
  self.fill_pta()
2495
2574
  self.fill_readiness(results_dir)
2575
+ self.sort_tables()
2496
2576
 
2497
2577
  @on(DataTable.RowSelected, "#failures")
2498
2578
  def failure_selected(self, event):
2499
- entries = [answer_entry(r) for r in self.fail_rows]
2579
+ # in the order shown, so [ and ] step through them as sorted
2580
+ entries = [answer_entry(self.fail_rows[int(r.key.value)]) for r in event.data_table.ordered_rows]
2500
2581
  i = event.cursor_row
2501
2582
  if not entries[i]:
2502
2583
  self.notify("This result is in the old format and has no stored answer.", severity="warning")
@@ -2507,7 +2588,6 @@ class ResultsScreen(Screen):
2507
2588
  # -- per question: tokens and time for every model on the same question
2508
2589
  @on(Select.Changed, "#pq-pack")
2509
2590
  @on(Select.Changed, "#pq-level")
2510
- @on(Select.Changed, "#pq-sort")
2511
2591
  @on(Checkbox.Changed, "#pq-partial")
2512
2592
  @on(Checkbox.Changed, "#pq-shared")
2513
2593
  @on(Checkbox.Changed, "#pq-disagree")
@@ -2515,6 +2595,45 @@ class ResultsScreen(Screen):
2515
2595
  if getattr(self, "rows", None) is not None:
2516
2596
  self.fill_per_question()
2517
2597
 
2598
+ @on(Select.Changed, "#pq-sort")
2599
+ def pq_sort_changed(self):
2600
+ """The Sort dropdown takes over from a column sorted by a click."""
2601
+ self.app.result_sorts.pop("per-question", None)
2602
+ self.per_question_changed()
2603
+
2604
+ @on(DataTable.HeaderSelected)
2605
+ def header_selected(self, event):
2606
+ """A click on a column name sorts by it; another click on it reverses the order."""
2607
+ t, name = event.data_table, column_name(event.label)
2608
+ prev = self.app.result_sorts.get(t.id)
2609
+ self.app.result_sorts[t.id] = (name, not prev[1] if prev and prev[0] == name else False)
2610
+ self.sort_table(t)
2611
+
2612
+ def sort_tables(self):
2613
+ """After a table is rebuilt: sort it again by the column picked (if that column is still there)."""
2614
+ for t in self.query(DataTable):
2615
+ self.sort_table(t)
2616
+
2617
+ def sort_table(self, t):
2618
+ name, desc = self.app.result_sorts.get(t.id, (None, False))
2619
+ found = None
2620
+ for col in t.ordered_columns:
2621
+ base = column_name(col.label)
2622
+ label = base + SORT_ARROWS[desc] if base == name else base
2623
+ if col.label.plain != label:
2624
+ col.label = Text(label)
2625
+ w = cell_len(label) # room for the arrow (a column keeps the width it was added with)
2626
+ col.content_width = max(col.content_width, w)
2627
+ if not col.auto_width:
2628
+ col.width = max(col.width, w)
2629
+ t._require_update_dimensions = True
2630
+ if base == name:
2631
+ found = col
2632
+ if found is not None:
2633
+ t.sort(found.key, key=lambda cell: sort_key(cell, desc))
2634
+ else:
2635
+ t.refresh()
2636
+
2518
2637
  @on(Button.Pressed, "#pq-models")
2519
2638
  @on(Button.Pressed, "#pta-models")
2520
2639
  def pick_models(self):
@@ -2579,6 +2698,7 @@ class ResultsScreen(Screen):
2579
2698
  for i, h in enumerate(header): # sized to the contents: added before the rows, a column keeps its header's width
2580
2699
  t.add_column(h, width=max(len(h), *(len(r[i]) for r in table)) if table else None)
2581
2700
  t.add_rows(table)
2701
+ self.sort_table(t)
2582
2702
 
2583
2703
  def fill_per_question(self):
2584
2704
  # a pack being rerun under new questions or settings: its old result file is on its way out,
@@ -2641,18 +2761,21 @@ class ResultsScreen(Screen):
2641
2761
  best = min((c["tokens"] for c in t["cells"].values() if c["tokens"] and c["passed"]), default=None)
2642
2762
  for m in models:
2643
2763
  c = t["cells"].get(m)
2644
- cells.append(self.pq_cell(c, best, here) if c else "[dim]-[/dim]")
2764
+ # a model's column sorts by its tokens
2765
+ cells.append(sort_cell(self.pq_cell(c, best, here), c["tokens"] or None) if c else "[dim]-[/dim]")
2645
2766
  spread = t["spread"]["tokens"]
2646
2767
  sp = f"[yellow]{spread:.1f}×[/yellow]" if spread >= 3 else (f"{spread:.1f}×" if len(t["cells"]) > 1 else "")
2647
2768
  desc = t["cells"][next(iter(t["cells"]))]["rows"][0].get("raw", {}).get("description") or t["name"]
2648
2769
  table.add_row(rich_escape(desc[:48]), t["suite"], diff_badge(t["difficulty"]), sp, *cells, key=t["test"])
2649
2770
  self.pq_tests[t["test"]] = t
2771
+ self.sort_table(table)
2772
+ self.sort_table(self.query_one("#pq-summary", DataTable))
2650
2773
  self.query_one("#pq-legend", Static).update(
2651
2774
  f"[dim]{len(tests)} questions. Each cell: result (passes/answers if repeated), median tokens, median seconds. "
2652
2775
  "[green]bold[/green] = fewest tokens among passing answers · [red]cut[/red] = hit max_tokens · "
2653
2776
  "◐ = pack unfinished (not in scores) · dim seconds = hosted, or measured on another machine; tokens compare "
2654
2777
  "across machines, seconds don't. ×tokens = most / fewest tokens across models. Enter on a cell opens the "
2655
- "answers.[/dim]" + ("\n[yellow]Left out, being rerun with other questions or settings: " + ", ".join(
2778
+ "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(
2656
2779
  f"{os.path.basename(os.path.dirname(p))} · {os.path.basename(p)[:-5]}" for p in sorted(self.superseded))
2657
2780
  + ". Only the new run's answers (◐) are shown for these.[/yellow]" if self.superseded else ""))
2658
2781
 
@@ -803,6 +803,18 @@ class PTAIndex(unittest.TestCase):
803
803
  picked = pta.index(rows, ["a", "few"]) # picked by hand: compared anyway
804
804
  self.assertEqual((picked["questions"], picked["left_out"]), (1, []))
805
805
 
806
+ def test_results_sort_values(self):
807
+ from tuieval.tui import diff_badge, sort_cell, sort_key, sort_value
808
+ got = [sort_value(c) for c in ("1,234 ★", "3.1k", "2.4×", "4m10s", "1h02m", "12.5s", diff_badge("hard"),
809
+ "[bold green]PASS[/]", "[green]OK 12s[/] [dim]measured[/dim]",
810
+ "[bold red]doesn't fit[/]", "Top 10 holdings")]
811
+ self.assertEqual(got, [1234, 3100, 2.4, 250, 3720, 12.5, 3, 3, 12, float("inf"), "top 10 holdings"])
812
+ self.assertEqual([sort_value(c) for c in ("-", "[dim]-[/dim]", "[dim]no data[/]", "")], [None] * 4)
813
+ self.assertEqual(sort_value(sort_cell("[green]✓[/green] 3.1k 12s", 3100)), 3100)
814
+ cells = ["3/4", "-", "10/20", "4/4", "abc"] # numbers, then text; empty cells last both ways
815
+ self.assertEqual(sorted(cells, key=sort_key), ["10/20", "3/4", "4/4", "abc", "-"])
816
+ self.assertEqual(sorted(cells, key=lambda c: sort_key(c, True)), ["4/4", "3/4", "10/20", "abc", "-"])
817
+
806
818
  def test_cli_report_and_tui(self):
807
819
  with tempfile.TemporaryDirectory() as tmp:
808
820
  ws = os.path.join(tmp, "ws")
@@ -857,8 +869,37 @@ class PTAIndex(unittest.TestCase):
857
869
  app.screen.fill_per_question()
858
870
  await pilot.pause(0.3)
859
871
  alone = pq.row_count
872
+ tab = app.screen.query_one(TabbedContent).active
873
+
874
+ async def click_header(t, name): # a real click on the column name
875
+ i = next(i for i, c in enumerate(t.ordered_columns) if c.label.plain.startswith(name))
876
+ x = sum(c.get_render_width(t) for c in t.ordered_columns[:i]) + 1
877
+ await pilot.click(t, offset=(x, 0))
878
+ await pilot.pause(0.2)
879
+ return [str(t.get_row_at(r)[0]) for r in range(t.row_count)], \
880
+ [c.label.plain for c in t.ordered_columns]
881
+ sorts = {}
882
+ pta_t = app.screen.query_one("#pta", DataTable)
883
+ sorts["up"], _ = await click_header(pta_t, "answers right")
884
+ sorts["down"], sorts["labels"] = await click_header(pta_t, "answers right")
885
+ app.pq_models = set()
886
+ app.screen.fill_pta() # rebuilt: still sorted
887
+ await pilot.pause(0.2)
888
+ sorts["kept"] = [str(pta_t.get_row_at(r)[0]) for r in range(pta_t.row_count)]
889
+ app.screen.query_one(TabbedContent).active = "tab-failures"
890
+ await pilot.pause(0.3)
891
+ f = app.screen.query_one("#failures", DataTable)
892
+ await click_header(f, "Test")
893
+ sorts["tests"], _ = await click_header(f, "Test")
894
+ sorts["tests"] = [str(f.get_row_at(r)[2]) for r in range(f.row_count)]
895
+ f.focus()
896
+ f.move_cursor(row=0)
897
+ await pilot.press("enter") # opens the row shown, not the first failure
898
+ await pilot.pause(0.3)
899
+ s = app.screen
900
+ sorts["opened"] = s.entries[s.index]["record"]["test"]
860
901
  print(json.dumps({"rows": rows, "bars": "P parsimony" in tri, "disagree": [both, alone],
861
- "tab": app.screen.query_one(TabbedContent).active}))
902
+ "tab": tab, "sorts": sorts}))
862
903
  asyncio.run(go())
863
904
  """)
864
905
  p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
@@ -869,6 +910,10 @@ class PTAIndex(unittest.TestCase):
869
910
  self.assertEqual(got["disagree"], [4, 0])
870
911
  self.assertEqual(got["tab"], "tab-pta") # Results opens on the PTA index
871
912
  self.assertEqual([(r[0], r[3]) for r in got["rows"]][0], ("good", "4/4"))
913
+ s = got["sorts"] # a click on a column name sorts by it, a second click reverses
914
+ self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
915
+ self.assertIn("answers right ▼", s["labels"])
916
+ self.assertEqual((s["tests"][0], s["opened"]), ("apps: d", "d"))
872
917
 
873
918
 
874
919
  class Unavailable(unittest.TestCase):
File without changes
File without changes