tuieval 0.2.0.dev10__tar.gz → 0.2.0.dev14__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 (71) hide show
  1. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/PKG-INFO +4 -3
  3. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/README.md +3 -2
  4. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/engine.py +39 -18
  6. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/packs.py +9 -0
  7. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/run_evals.py +34 -4
  8. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/tui.py +212 -19
  9. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/tests/test_tuieval.py +109 -2
  10. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/.gitignore +0 -0
  11. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/LICENSE +0 -0
  12. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/RELEASING.md +0 -0
  13. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/README.md +0 -0
  14. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/favicon.ico +0 -0
  15. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/readme-header.png +0 -0
  16. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/social-preview.png +0 -0
  17. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-1024.png +0 -0
  18. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-128.png +0 -0
  19. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-16.png +0 -0
  20. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-16.svg +0 -0
  21. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-256.png +0 -0
  22. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-32.png +0 -0
  23. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-48.png +0 -0
  24. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-512.png +0 -0
  25. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-64.png +0 -0
  26. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  27. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/brand/tuieval-icon.svg +0 -0
  28. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/images/tui-setup.png +0 -0
  29. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/models.md +0 -0
  30. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/docs/writing-packs.md +0 -0
  31. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/pyproject.toml +0 -0
  32. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/__init__.py +0 -0
  33. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/__main__.py +0 -0
  34. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/cli.py +0 -0
  35. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/client.py +0 -0
  36. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/compare.py +0 -0
  37. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/export.py +0 -0
  38. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/graders/__init__.py +0 -0
  39. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/graders/answer.py +0 -0
  40. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/graders/code.py +0 -0
  41. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/graders/rag.py +0 -0
  42. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/graders/reply.py +0 -0
  43. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/graders/tool_call.py +0 -0
  44. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/machines.py +0 -0
  45. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/profiles.py +0 -0
  46. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/remove.py +0 -0
  47. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/scaffold.py +0 -0
  48. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/selftest.py +0 -0
  49. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/models.toml +0 -0
  50. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  51. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/answer/system.txt +0 -0
  52. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  53. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/code/pack.toml +0 -0
  54. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/code/system.txt +0 -0
  55. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  56. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  57. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/rag/system.txt +0 -0
  58. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  59. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  60. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/reply/system.txt +0 -0
  61. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  62. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  63. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  64. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  66. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/tune.py +0 -0
  67. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/verdict.py +0 -0
  68. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/watch_proxy.py +0 -0
  69. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/workspace.py +0 -0
  70. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/src/tuieval/yamlout.py +0 -0
  71. {tuieval-0.2.0.dev10 → tuieval-0.2.0.dev14}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Run only some tests of a pack: `e` in the TUI, or `tuieval run --tests`. Runs add up; `--force` reruns just the picked tests. Presets keep the pick.
5
6
  - `tuieval tune` scores options on projected full-length answers (`[tune] answer_tokens`, default 1024), so generation speed counts as in real runs.
6
7
  - `tuieval tune` reports an option's memory cost instead of rejecting it; it rejects only on critical memory pressure or over `[tune] swap_limit_mb`.
7
8
  - Warm-start tuning recognises fine-tunes whose GGUF describes the MTP draft layer differently (e.g. listing KV heads per layer) as the same model shape, so they start from an already-tuned sibling instead of tuning in full.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev10
3
+ Version: 0.2.0.dev14
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
@@ -32,7 +32,7 @@ Run the same eval packs against local models (llama.cpp GGUFs, LM Studio, Ollama
32
32
 
33
33
  tuieval measures **accuracy, speed and token use** together, and gives a **PASS / FAIL / INCONCLUSIVE** verdict per use case. Grading is automatic; there's no LLM judge.
34
34
 
35
- Public benchmarks leak into training data and You know your workload better than anyone. tuieval ships with **no built-in benchmark**.
35
+ You know your workload better than anyone. tuieval ships with **no built-in benchmark**.
36
36
  Instead you build *eval packs* from what you actually do: questions with checkable answers, in your domain, with your rules
37
37
  Then you get an answer to the questions that matter:
38
38
 
@@ -125,7 +125,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
125
125
 
126
126
  ## The TUI
127
127
 
128
- 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).
128
+ 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.
129
129
  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.
130
130
  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). Runs started while one is going are queued.
131
131
  4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
@@ -137,6 +137,7 @@ Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scan
137
137
  ```bash
138
138
  tuieval run # Screen every model on every pack
139
139
  tuieval run --tier certify --only a,b --packs support-bot,coding
140
+ tuieval run --packs coding --tests parse-dates,fix-bug # only these tests (or --tests coding:parse-dates)
140
141
  tuieval run --dry-run # the plan and server commands
141
142
  tuieval verdict # PASS / FAIL / INCONCLUSIVE per model and use case
142
143
  tuieval report # the same with evidence, as a markdown file
@@ -6,7 +6,7 @@ Run the same eval packs against local models (llama.cpp GGUFs, LM Studio, Ollama
6
6
 
7
7
  tuieval measures **accuracy, speed and token use** together, and gives a **PASS / FAIL / INCONCLUSIVE** verdict per use case. Grading is automatic; there's no LLM judge.
8
8
 
9
- Public benchmarks leak into training data and You know your workload better than anyone. tuieval ships with **no built-in benchmark**.
9
+ You know your workload better than anyone. tuieval ships with **no built-in benchmark**.
10
10
  Instead you build *eval packs* from what you actually do: questions with checkable answers, in your domain, with your rules
11
11
  Then you get an answer to the questions that matter:
12
12
 
@@ -99,7 +99,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
99
99
 
100
100
  ## The TUI
101
101
 
102
- 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).
102
+ 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.
103
103
  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.
104
104
  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). Runs started while one is going are queued.
105
105
  4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
@@ -111,6 +111,7 @@ Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scan
111
111
  ```bash
112
112
  tuieval run # Screen every model on every pack
113
113
  tuieval run --tier certify --only a,b --packs support-bot,coding
114
+ tuieval run --packs coding --tests parse-dates,fix-bug # only these tests (or --tests coding:parse-dates)
114
115
  tuieval run --dry-run # the plan and server commands
115
116
  tuieval verdict # PASS / FAIL / INCONCLUSIVE per model and use case
116
117
  tuieval report # the same with evidence, as a markdown file
@@ -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.dev10'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev10')
21
+ __version__ = version = '0.2.0.dev14'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev14')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -38,6 +38,7 @@ import mimetypes
38
38
  import os
39
39
  import random
40
40
  import re
41
+ import shutil
41
42
  import signal
42
43
  import socket
43
44
  import statistics
@@ -358,6 +359,7 @@ def load_presets(path):
358
359
  tags = ["moe"] # models with any of these tags …
359
360
  models = ["foo", "bar"] # … and/or these labels (omit both for every model)
360
361
  repeat = 3
362
+ tests = { coding = ["parse-dates"] } # optional: only these tests of a pack
361
363
  """
362
364
  if not os.path.isfile(path):
363
365
  return {}
@@ -366,19 +368,24 @@ def load_presets(path):
366
368
 
367
369
 
368
370
  def resolve_preset(cfg, preset, pack_names):
369
- """(labels, packs, repeat) for a preset; unknown names are dropped."""
371
+ """(labels, packs, repeat, tests) for a preset; unknown names are dropped. tests: {pack: [test
372
+ ids]} for packs where only some tests run."""
370
373
  labels = [m["label"] for m in cfg["models"]]
371
374
  if preset.get("models") or preset.get("tags"):
372
375
  want, tags = set(preset.get("models", [])), set(preset.get("tags", []))
373
376
  labels = [m["label"] for m in cfg["models"] if m["label"] in want or tags & set(m["tags"])]
374
377
  packs = [p for p in preset.get("packs", pack_names) if p in pack_names]
375
- return labels, packs, preset.get("repeat")
378
+ tests = {p: list(ids) for p, ids in (preset.get("tests") or {}).items() if p in packs and ids}
379
+ return labels, packs, preset.get("repeat"), tests
376
380
 
377
381
 
378
- def save_preset(path, name, labels, packs, repeat):
382
+ def save_preset(path, name, labels, packs, repeat, tests=None):
379
383
  """Add or replace one preset, keeping the rest of the file as written."""
384
+ tests = {p: ids for p, ids in (tests or {}).items() if p in packs and ids}
380
385
  block = (f"[{name}]\nmodels = [{', '.join(json.dumps(l) for l in labels)}]\n"
381
- f"packs = [{', '.join(json.dumps(p) for p in packs)}]\n" + (f"repeat = {int(repeat)}\n" if repeat else ""))
386
+ f"packs = [{', '.join(json.dumps(p) for p in packs)}]\n" + (f"repeat = {int(repeat)}\n" if repeat else "")
387
+ + ("tests = { " + ", ".join(f"{json.dumps(p)} = [{', '.join(json.dumps(i) for i in ids)}]"
388
+ for p, ids in tests.items()) + " }\n" if tests else ""))
382
389
  text = open(path).read() if os.path.isfile(path) else \
383
390
  "# Saved selections for the TUI (p) and tuieval run --preset <name>. See engine.load_presets.\n"
384
391
  pattern = re.compile(rf"^\[{re.escape(name)}\]\n(?:(?!\[).*\n?)*", re.MULTILINE)
@@ -394,12 +401,13 @@ class Job:
394
401
  model: dict
395
402
  pack: packs_mod.Pack
396
403
  repeat: int
397
- tests: list # the tests this tier runs
404
+ tests: list # the tests this tier runs (or the ones picked)
398
405
  out_path: str
399
406
  sampling: dict
400
407
  tier: str = "certify"
401
408
  settings: dict = dataclasses.field(default_factory=dict) # sampling + serving identity (fingerprinted)
402
409
  merge: bool = True # keep matching earlier results and only run what's missing
410
+ redo: bool = False # rerun the picked tests, keeping the pack's other answers
403
411
  status: str = "waiting" # waiting, loading, running, done, failed, skipped
404
412
  note: str = ""
405
413
  discards: list = dataclasses.field(default_factory=list) # [(what, answers, why)] set aside on start
@@ -868,7 +876,7 @@ class Engine:
868
876
  if job.merge:
869
877
  data = self._result(job.out_path)
870
878
  if data and data["pack"]["fingerprint"] == job.pack.fingerprint and settings_match(data, job.settings):
871
- kept = data["results"]
879
+ kept = self._kept(job, data["results"])
872
880
  if kept:
873
881
  earlier = self._file_sittings(data["run"])
874
882
  head, new = self._partial_lines(job.out_path + ".partial.jsonl")
@@ -877,6 +885,14 @@ class Engine:
877
885
  saved = self._saved_sittings(job)
878
886
  return kept, new, earlier + saved + self._rebuild_sittings(new, saved + earlier)
879
887
 
888
+ @staticmethod
889
+ def _kept(job, rows):
890
+ """A finished file's rows that stay: all of them, less the picked tests' when redoing them."""
891
+ if not job.redo:
892
+ return rows
893
+ redo = {t["id"] for t in job.tests}
894
+ return [r for r in rows if r["test"] not in redo]
895
+
880
896
  def _fill_progress(self, job):
881
897
  """Set a job's done/passed/failed and earlier sittings from what's stored."""
882
898
  kept, new, job.earlier = self._stored(job)
@@ -927,11 +943,13 @@ class Engine:
927
943
  return not current and header_matches(head, pack.fingerprint, settings)
928
944
 
929
945
  # ---- planning
930
- def plan(self, labels, pack_names, repeat=None, tier="screen", force=False):
946
+ def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None):
931
947
  """Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
932
948
  tests). repeat: times each question is asked; None = the tier default (1 for smoke and
933
- screen, the pack's certification repeats for certify). Earlier matching results are kept
934
- and only missing answers are run, unless force."""
949
+ screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
950
+ those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
951
+ matching results are kept and only missing answers are run, unless force; force with
952
+ picked tests reruns those and keeps the pack's other answers."""
935
953
  if tier not in TIERS:
936
954
  raise ValueError(f"tier must be one of {TIERS}")
937
955
  jobs = []
@@ -940,8 +958,10 @@ class Engine:
940
958
  for name in pack_names:
941
959
  pack = self.packs[name]
942
960
  reps = repeat or self.default_repeat(pack, tier)
943
- job = Job(m, pack, reps, pack.select(tier), self.result_path(label, name, tier),
944
- effective_sampling(self.cfg, m), tier=tier, merge=not force, settings=self.settings(m))
961
+ picked = (tests or {}).get(name)
962
+ job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
963
+ self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
964
+ merge=not force or bool(picked), redo=force and bool(picked), settings=self.settings(m))
945
965
  sv = self.serving(m)
946
966
  status = self.result_status(label, name, tier)
947
967
  job.discards = self._discards(job)
@@ -958,7 +978,7 @@ class Engine:
958
978
  elif missing:
959
979
  job.status, job.note = "skipped", f"{', '.join(missing)} not installed in tuieval's Python"
960
980
  elif status and status.startswith("outdated"):
961
- job.merge, job.note = False, status.split(": ", 1)[-1] + ", rerunning"
981
+ job.merge, job.redo, job.note = False, False, status.split(": ", 1)[-1] + ", rerunning"
962
982
  if not force: # a rerun under the new settings already under way resumes
963
983
  self._fill_progress(job)
964
984
  if job.done:
@@ -1656,7 +1676,7 @@ class Engine:
1656
1676
  data = self._result(job.out_path)
1657
1677
  if data and data["pack"]["fingerprint"] == job.pack.fingerprint and \
1658
1678
  settings_match(data, job.settings):
1659
- kept = data["results"]
1679
+ kept = self._kept(job, data["results"])
1660
1680
  if kept:
1661
1681
  job.earlier = self._file_sittings(data["run"])
1662
1682
  new = []
@@ -1789,18 +1809,19 @@ class Engine:
1789
1809
  job.note = f"{job.passed}/{job.done} passed"
1790
1810
  self.emit("job_done", job=job)
1791
1811
 
1792
- def _archive(self, job):
1793
- """Keep a result file that is about to be replaced (rerun or outdated) in history/."""
1812
+ def _archive(self, job, keep=False):
1813
+ """Keep a result file that is about to be replaced (rerun or outdated) in history/; keep:
1814
+ copy it (some of its answers stay in the new file)."""
1794
1815
  if job.tier == "smoke" or not os.path.isfile(job.out_path):
1795
1816
  return
1796
1817
  hist = os.path.join(os.path.dirname(job.out_path), "history")
1797
1818
  os.makedirs(hist, exist_ok=True)
1798
1819
  stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(job.out_path)))
1799
- os.replace(job.out_path, os.path.join(hist, f"{job.pack.name}-{stamp}.json"))
1820
+ (shutil.copy2 if keep else os.replace)(job.out_path, os.path.join(hist, f"{job.pack.name}-{stamp}.json"))
1800
1821
 
1801
1822
  def _write_result(self, job, records, run_info):
1802
- if not job.merge:
1803
- self._archive(job)
1823
+ if not job.merge or job.redo:
1824
+ self._archive(job, keep=job.redo)
1804
1825
  m = job.model
1805
1826
  data = {
1806
1827
  "format": RESULT_FORMAT,
@@ -55,6 +55,15 @@ class Pack:
55
55
  """Python modules the pack needs (the `needs` entries that aren't capabilities)."""
56
56
  return [n for n in self.needs if n not in CAPABILITIES]
57
57
 
58
+ def pick(self, ids):
59
+ """The tests with these ids, in the pack's order. Unknown ids raise PackError."""
60
+ known = {t["id"] for t in self.tests}
61
+ bad = [i for i in ids if i not in known]
62
+ if bad:
63
+ raise PackError(f"pack {self.name!r} has no test {', '.join(map(repr, bad))}; "
64
+ f"its tests are: {', '.join(t['id'] for t in self.tests)}")
65
+ return [t for t in self.tests if t["id"] in set(ids)]
66
+
58
67
  def select(self, tier):
59
68
  """The tests a tier runs: smoke = first 3; screen = `screen` tests spread evenly over
60
69
  categories (one variant per group); certify = all."""
@@ -10,6 +10,7 @@
10
10
  tuieval capture logs/live/<file> --pack X # turn a real failure into a new test
11
11
  tuieval run --only my-model --packs my-pack,other-pack
12
12
  tuieval run --tags moe --packs my-pack # models tagged "moe"
13
+ tuieval run --packs my-pack --tests a,b # only these tests of a pack (or --tests my-pack:a,other:b)
13
14
  tuieval run --preset nightly # a selection saved in the TUI (presets.toml)
14
15
  tuieval run --tier smoke --only my-model # 3 tests per pack, 1 repeat: check paths and flags
15
16
  tuieval run --tier smoke --only openrouter:qwen/qwen3-32b # any OpenRouter model, no models.toml edit
@@ -36,6 +37,7 @@ import sys
36
37
  import threading
37
38
 
38
39
  from . import engine
40
+ from . import packs as packs_mod
39
41
  from . import workspace
40
42
 
41
43
  BOLD, DIM, RED, GREEN, CYAN, YELLOW, RESET = "\033[1m", "\033[2m", "\033[31m", "\033[32m", "\033[36m", "\033[33m", "\033[0m"
@@ -605,6 +607,27 @@ COMMANDS = {"add": cmd_add, "list": cmd_list, "scan": cmd_scan, "regrade": cmd_r
605
607
  "export": cmd_export, "remove": cmd_remove}
606
608
 
607
609
 
610
+ def parse_tests(text, pack_names, known):
611
+ """({pack: [test ids]}, packs to run) from --tests. A bare id needs exactly one pack chosen;
612
+ pack:id names its pack, and without --packs the packs named are the ones that run."""
613
+ tests = {}
614
+ for item in filter(None, (x.strip() for x in text.split(","))):
615
+ pack, _, tid = item.rpartition(":")
616
+ if not pack:
617
+ if not pack_names or len(pack_names) != 1:
618
+ sys.exit(f"--tests {item}: say which pack, as pack:{item} (or choose one pack with --packs)")
619
+ pack = pack_names[0]
620
+ if pack not in known:
621
+ sys.exit(f"--tests {item}: unknown pack {pack!r}; packs are {', '.join(known)}")
622
+ tests.setdefault(pack, []).append(tid)
623
+ if pack_names is None:
624
+ pack_names = list(tests)
625
+ missing = [p for p in tests if p not in pack_names]
626
+ if missing:
627
+ sys.exit(f"--tests names pack(s) {missing} that aren't in --packs")
628
+ return tests, pack_names
629
+
630
+
608
631
  def main(argv=None):
609
632
  """tuieval run [options] (argv without the "run")."""
610
633
  argv = sys.argv[1:] if argv is None else argv
@@ -615,7 +638,9 @@ def main(argv=None):
615
638
  p.add_argument("--tags", help="comma-separated tags; models having any of them")
616
639
  p.add_argument("--preset", help="a saved selection from presets.toml")
617
640
  p.add_argument("--packs", help="comma-separated packs (default: all)")
618
- p.add_argument("--force", action="store_true", help="rerun packs that already have current results")
641
+ p.add_argument("--tests", help="comma-separated test ids to run instead of the tier's sample: id with one "
642
+ "pack in --packs, else pack:id (and --packs defaults to those packs)")
643
+ p.add_argument("--force", action="store_true", help="rerun packs (or the --tests) that already have current results")
619
644
  p.add_argument("--repeat", type=int, help="times each question is asked, any tier (default: 1 for smoke and "
620
645
  "screen, the pack's certification count for certify)")
621
646
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
@@ -642,7 +667,7 @@ def main(argv=None):
642
667
  labels = [l for l in labels if want & set(e.model(l)["tags"])]
643
668
  if not labels:
644
669
  sys.exit(f"no models tagged {sorted(want)}")
645
- pack_names = list(e.packs)
670
+ pack_names, tests = list(e.packs), {}
646
671
  if not pack_names:
647
672
  sys.exit("no packs in this workspace yet: tuieval new-pack <name> creates one (see docs/writing-packs.md)")
648
673
  if args.preset:
@@ -650,14 +675,19 @@ def main(argv=None):
650
675
  "presets.toml"))
651
676
  if args.preset not in presets:
652
677
  sys.exit(f"unknown preset {args.preset!r}; presets: {', '.join(presets) or 'none (save one in the TUI with p)'}")
653
- labels, pack_names, preset_repeat = engine.resolve_preset(e.cfg, presets[args.preset], list(e.packs))
678
+ labels, pack_names, preset_repeat, tests = engine.resolve_preset(e.cfg, presets[args.preset], list(e.packs))
654
679
  args.repeat = args.repeat or preset_repeat
655
680
  if args.packs:
656
681
  pack_names = [x.strip() for x in args.packs.split(",")]
657
682
  bad = [x for x in pack_names if x not in e.packs]
658
683
  if bad:
659
684
  sys.exit(f"unknown pack(s) {bad}; packs are {', '.join(e.packs)}")
660
- jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force)
685
+ if args.tests:
686
+ tests, pack_names = parse_tests(args.tests, pack_names if (args.packs or args.preset) else None, e.packs)
687
+ try:
688
+ jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests)
689
+ except packs_mod.PackError as err:
690
+ sys.exit(str(err))
661
691
 
662
692
  if args.dry_run:
663
693
  seen = set()
@@ -21,6 +21,7 @@ import argparse
21
21
  import glob
22
22
  import json
23
23
  import os
24
+ import re
24
25
  import threading
25
26
  import time
26
27
 
@@ -398,6 +399,7 @@ class SetupScreen(Screen):
398
399
  Binding("a", "add_model", "Add model"),
399
400
  Binding("m", "scan", "Scan for models"),
400
401
  Binding("p", "presets", "Presets"),
402
+ Binding("e", "pick_tests", "Pick tests"),
401
403
  Binding("t", "tune", "Tune speed"),
402
404
  Binding("r", "results", "Results"),
403
405
  Binding("x", "hide_model", "Hide/unhide"),
@@ -426,7 +428,7 @@ class SetupScreen(Screen):
426
428
  yield Static(id="machine")
427
429
  with Horizontal(id="pickers"):
428
430
  with Vertical(classes="picker"):
429
- yield Label("[b]Eval packs[/b] (space to toggle)")
431
+ yield Label("[b]Eval packs[/b] (space to toggle · e picks tests)")
430
432
  yield SelectionList(id="suites")
431
433
  with Vertical(classes="picker"):
432
434
  yield Label(id="models-legend")
@@ -455,6 +457,7 @@ class SetupScreen(Screen):
455
457
 
456
458
  def on_mount(self):
457
459
  self.selected_models = set()
460
+ self.picks = {} # pack -> test ids to run instead of the tier's usual tests
458
461
  self.refresh_machine()
459
462
  self.refresh_packs()
460
463
  self.refresh_models()
@@ -471,14 +474,19 @@ class SetupScreen(Screen):
471
474
  def refresh_packs(self):
472
475
  e = self.app.engine
473
476
  packs = self.query_one("#suites", SelectionList)
474
- keep = set(packs.selected)
477
+ keep, at = set(packs.selected), packs.highlighted
475
478
  packs.clear_options()
476
479
  for name, pk in e.packs.items():
480
+ picked = self.current_picks().get(name)
481
+ count = (f"[b cyan]{len(picked)}/{len(pk.tests)} tests picked[/]" if picked
482
+ else f"[dim]{len(pk.tests)} tests[/dim]")
477
483
  needs = f" [magenta]{', '.join(pk.needs)}[/magenta]" if pk.needs else ""
478
484
  mix = {lvl: sum(t.get("difficulty") == lvl for t in pk.tests) for lvl in ("easy", "medium", "hard")}
479
485
  mixed = " ".join(f"[{DIFF_STYLE[l]}]{n}{l[0].upper()}[/]" for l, n in mix.items() if n)
480
- packs.add_option(Selection(f"[dim]{pk.group} ·[/dim] {pk.label} [dim]{len(pk.tests)} tests[/dim] {mixed}{needs}",
486
+ packs.add_option(Selection(f"[dim]{pk.group} ·[/dim] {pk.label} {count} {mixed}{needs}",
481
487
  name, name in keep))
488
+ if at is not None and at < packs.option_count:
489
+ packs.highlighted = at
482
490
  if not e.packs:
483
491
  for i, line in enumerate(NO_PACKS):
484
492
  packs.add_option(Selection(line, f"\x00nopacks:{i}", disabled=True))
@@ -637,6 +645,38 @@ class SetupScreen(Screen):
637
645
  list(self.query_one("#suites", SelectionList).selected), repeat,
638
646
  self.tier(), self.query_one("#force", Checkbox).value)
639
647
 
648
+ def current_picks(self, suites=None):
649
+ """{pack: picked test ids} for packs that still have those tests (and, given suites, are ticked)."""
650
+ e = self.app.engine
651
+ out = {}
652
+ for name, ids in getattr(self, "picks", {}).items():
653
+ pk = e.packs.get(name)
654
+ known = [i for i in ids if pk and any(t["id"] == i for t in pk.tests)]
655
+ if known and (suites is None or name in suites):
656
+ out[name] = known
657
+ return out
658
+
659
+ def action_pick_tests(self):
660
+ """Choose which tests of the highlighted pack run (none ticked: the tier's usual tests)."""
661
+ packs = self.query_one("#suites", SelectionList)
662
+ opt = packs.highlighted_option
663
+ if opt is None or opt.value not in self.app.engine.packs:
664
+ self.notify("Highlight a pack first.", severity="warning")
665
+ return
666
+ name = opt.value
667
+
668
+ def done(ids):
669
+ if ids is None:
670
+ return
671
+ if ids:
672
+ self.picks[name] = ids
673
+ packs.select(name)
674
+ else:
675
+ self.picks.pop(name, None)
676
+ self.refresh_packs()
677
+ self.update_estimate()
678
+ self.app.push_screen(PickTestsScreen(self.app.engine.packs[name], self.current_picks().get(name, [])), done)
679
+
640
680
  @on(SelectionList.SelectedChanged)
641
681
  @on(Input.Changed)
642
682
  @on(Checkbox.Changed)
@@ -658,10 +698,11 @@ class SetupScreen(Screen):
658
698
  if not labels or not suites:
659
699
  est.update("[dim]Select at least one eval pack and one model.[/dim]" + self.queue_hint())
660
700
  return
661
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force)
701
+ picks = self.current_picks(suites)
702
+ jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
662
703
  rerun = not any(j.status == "waiting" for j in jobs)
663
704
  if rerun: # everything has results: Start runs it again, so estimate that
664
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force=True)
705
+ jobs = self.app.engine.plan(labels, suites, repeat, tier, True, picks)
665
706
  run = [j for j in jobs if j.status == "waiting"]
666
707
  skipped = [j for j in jobs if j.status == "skipped"]
667
708
  secs, notes = self.app.engine.estimate_seconds(jobs)
@@ -672,7 +713,10 @@ class SetupScreen(Screen):
672
713
  f"[b]{reps_text}[/b] repeat(s) = "
673
714
  f"[b]{todo:,}[/b] answers · about [b]{fmt_secs(secs)}[/b]"
674
715
  + (f" [dim](rough: {'; '.join(notes)})[/dim]" if notes else ""))
675
- if tier == "screen":
716
+ if picks:
717
+ text += f"\n[cyan]Picked tests only in {', '.join(e.packs[p].label for p in picks)}[/cyan]" \
718
+ "[dim] (e changes; a pack with picked tests can't PASS until all its tests have run)[/dim]"
719
+ elif tier == "screen":
676
720
  text += "\n[dim]Screening can only say FAIL or promising; PASS needs Certify.[/dim]"
677
721
  finished = [j for j in jobs if j.status == "done"]
678
722
  if finished:
@@ -708,6 +752,8 @@ class SetupScreen(Screen):
708
752
  return
709
753
  e = self.app.engine
710
754
  self.selected_models = {l for l in sel.get("models", []) if any(m["label"] == l for m in e.cfg["models"])}
755
+ self.picks = {p: list(ids) for p, ids in (sel.get("tests") or {}).items()}
756
+ self.refresh_packs()
711
757
  packs = self.query_one("#suites", SelectionList)
712
758
  packs.deselect_all()
713
759
  for name in sel.get("packs", []):
@@ -726,13 +772,14 @@ class SetupScreen(Screen):
726
772
 
727
773
  def action_presets(self):
728
774
  labels, packs, repeat, _, _ = self.settings()
775
+ tests = self.current_picks(packs)
729
776
 
730
777
  def done(result):
731
778
  if result and result[0] == "load":
732
779
  self.apply_selection(result[1])
733
780
  elif result and result[0] == "saved":
734
781
  self.notify(f"Saved preset '{result[1]}'. Also usable as tuieval run --preset {result[1]}")
735
- self.app.push_screen(PresetsScreen(labels, packs, repeat), done)
782
+ self.app.push_screen(PresetsScreen(labels, packs, repeat, tests), done)
736
783
 
737
784
  def action_scan(self):
738
785
  def done(added):
@@ -750,13 +797,14 @@ class SetupScreen(Screen):
750
797
  if not labels or not suites:
751
798
  self.notify("Select at least one eval pack and one model.", severity="warning")
752
799
  return
753
- sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force}
754
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force)
800
+ picks = self.current_picks(suites)
801
+ sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks}
802
+ jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
755
803
  rerun = False
756
804
  if not any(j.status == "waiting" for j in jobs):
757
805
  # Everything selected already has results: Start means run it again (earlier results
758
806
  # are kept in history/). Packs skipped for another reason (no vision, …) stay skipped.
759
- jobs = self.app.engine.plan(labels, suites, repeat, tier, force=True)
807
+ jobs = self.app.engine.plan(labels, suites, repeat, tier, True, picks)
760
808
  if not any(j.status == "waiting" for j in jobs):
761
809
  reasons = sorted({j.note for j in jobs if j.status == "skipped"})
762
810
  self.notify(f"Nothing selected can run: {'; '.join(reasons)}", severity="warning")
@@ -1102,9 +1150,9 @@ class PresetsScreen(ModalScreen):
1102
1150
  """Load a saved selection, or save the current one (presets.toml)."""
1103
1151
  BINDINGS = [Binding("escape", "cancel", "Cancel")]
1104
1152
 
1105
- def __init__(self, labels, packs, repeat):
1153
+ def __init__(self, labels, packs, repeat, tests=None):
1106
1154
  super().__init__()
1107
- self.current = (labels, packs, repeat)
1155
+ self.current = (labels, packs, repeat, tests or {})
1108
1156
 
1109
1157
  def compose(self) -> ComposeResult:
1110
1158
  with Vertical(id="dialog"):
@@ -1123,7 +1171,8 @@ class PresetsScreen(ModalScreen):
1123
1171
  for name, pr in self.presets.items():
1124
1172
  who = ", ".join(pr.get("models", [])) or ("tags " + ", ".join(pr["tags"]) if pr.get("tags") else "all models")
1125
1173
  opts.add_option(f"[b]{name}[/b] [dim]{', '.join(pr.get('packs', ['all packs']))} · {who}"
1126
- f"{' · repeat ' + str(pr['repeat']) if pr.get('repeat') else ''}[/dim]")
1174
+ f"{' · repeat ' + str(pr['repeat']) if pr.get('repeat') else ''}"
1175
+ f"{' · picked tests in ' + ', '.join(pr['tests']) if pr.get('tests') else ''}[/dim]")
1127
1176
  if not self.presets:
1128
1177
  opts.add_option("[dim]No presets yet: select models and packs, then save them here.[/dim]")
1129
1178
  opts.disabled = True
@@ -1131,14 +1180,15 @@ class PresetsScreen(ModalScreen):
1131
1180
  @on(OptionList.OptionSelected, "#presets")
1132
1181
  def load(self, event):
1133
1182
  name = list(self.presets)[event.option_index]
1134
- labels, packs, repeat = engine.resolve_preset(self.app.engine.cfg, self.presets[name], list(self.app.engine.packs))
1135
- self.dismiss(("load", {"models": labels, "packs": packs, "repeat": repeat}))
1183
+ labels, packs, repeat, tests = engine.resolve_preset(self.app.engine.cfg, self.presets[name],
1184
+ list(self.app.engine.packs))
1185
+ self.dismiss(("load", {"models": labels, "packs": packs, "repeat": repeat, "tests": tests}))
1136
1186
 
1137
1187
  @on(Button.Pressed, "#save")
1138
1188
  @on(Input.Submitted, "#name")
1139
1189
  def save(self):
1140
1190
  name = self.query_one("#name", Input).value.strip()
1141
- labels, packs, repeat = self.current
1191
+ labels, packs, repeat, tests = self.current
1142
1192
  err = self.query_one("#error", Static)
1143
1193
  if not name or not engine.LABEL_RE.match(name):
1144
1194
  err.update("[red]Use lowercase letters, digits, '.', '_' or '-'.[/red]")
@@ -1146,7 +1196,7 @@ class PresetsScreen(ModalScreen):
1146
1196
  if not labels or not packs:
1147
1197
  err.update("[red]Select at least one model and one pack first.[/red]")
1148
1198
  return
1149
- engine.save_preset(self.app.presets_path, name, labels, packs, repeat)
1199
+ engine.save_preset(self.app.presets_path, name, labels, packs, repeat, tests)
1150
1200
  self.dismiss(("saved", name))
1151
1201
 
1152
1202
  @on(Button.Pressed, "#cancel")
@@ -1215,13 +1265,24 @@ class QueuedRun:
1215
1265
  self.jobs = jobs
1216
1266
  self.est = eng.estimate_seconds(jobs)[0]
1217
1267
 
1268
+ def tests(self, eng):
1269
+ """The picked tests that are still in their packs (a pack edited while queued drops the rest)."""
1270
+ out = {}
1271
+ for p, ids in (self.sel.get("tests") or {}).items():
1272
+ pk = eng.packs.get(p)
1273
+ known = [i for i in ids if pk and any(t["id"] == i for t in pk.tests)]
1274
+ if known:
1275
+ out[p] = known
1276
+ return out
1277
+
1218
1278
  def line(self):
1219
1279
  return f"{self.title} [dim]~{fmt_secs(self.est)} · queued {time.strftime('%H:%M', time.localtime(self.queued))}[/dim]"
1220
1280
 
1221
1281
  def make(self, eng):
1222
1282
  """(RunScreen, None), or (None, why it can't start) when its turn comes."""
1223
1283
  sel = self.sel
1224
- jobs = eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], sel["force"] or self.rerun)
1284
+ jobs = eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], sel["force"] or self.rerun,
1285
+ self.tests(eng))
1225
1286
  waiting = [j for j in jobs if j.status == "waiting"]
1226
1287
  if not waiting:
1227
1288
  return None, "nothing left to run (earlier runs finished it)"
@@ -1287,6 +1348,138 @@ class ConfirmScreen(ModalScreen):
1287
1348
  self.dismiss(event.button.id == "yes")
1288
1349
 
1289
1350
 
1351
+ def _words(text):
1352
+ return re.findall(r"[a-z0-9]+", str(text).lower())
1353
+
1354
+
1355
+ def test_note(t):
1356
+ """What a test's row shows after its id: the description's words the id doesn't already say,
1357
+ and the category unless the id or description already names it."""
1358
+ desc, ids = str(t.get("description") or ""), [w for w in _words(t["id"]) if not re.fullmatch(r"v\d+", w)]
1359
+ note = desc
1360
+ tokens = list(re.finditer(r"[a-z0-9]+", desc.lower()))
1361
+ if set(w.group() for w in tokens) <= set(_words(t["id"])):
1362
+ note = "" # the id in other words
1363
+ elif len(ids) > 1: # "normal entry #1, pretty JSON" for normal-entry-1-v1
1364
+ n = 0
1365
+ while n < len(tokens) and n < len(ids) and tokens[n].group() == ids[n]:
1366
+ n += 1
1367
+ if n == len(ids):
1368
+ note = desc[tokens[n - 1].end():].strip(" ,;:·-#")
1369
+ cat = str(t.get("category") or "")
1370
+ said = " ".join(_words(t["id"]) + _words(note))
1371
+ if cat and " ".join(_words(cat)) not in said:
1372
+ note = f"{note} · {cat}" if note else cat
1373
+ return note
1374
+
1375
+
1376
+ class PickTestsScreen(ModalScreen):
1377
+ """Pick which tests of one pack run. Dismisses with the ticked ids in pack order (empty = the
1378
+ tier's usual tests), or None to keep the current pick."""
1379
+ BINDINGS = [Binding("escape", "cancel", "Cancel"),
1380
+ Binding("space", "toggle", "Tick", show=False),
1381
+ Binding("a", "toggle_shown", "Tick all shown")]
1382
+
1383
+ def __init__(self, pack, chosen):
1384
+ super().__init__()
1385
+ self.pack, self.chosen, self.shown = pack, set(chosen), []
1386
+
1387
+ def compose(self) -> ComposeResult:
1388
+ with Vertical(id="dialog", classes="wide"):
1389
+ yield Label(f"[b]Pick tests: {rich_escape(self.pack.label)}[/b]\n[dim]space ticks · a ticks all shown · "
1390
+ "type to filter, enter to go to the list · none ticked = the tier's usual tests[/dim]")
1391
+ yield Input(placeholder="filter by id, description or category", id="pick-filter")
1392
+ yield OptionList(id="pick-tests")
1393
+ yield Static(id="pick-count")
1394
+ with Horizontal(classes="dialog-buttons"):
1395
+ yield Button("Use these", id="pick-apply", variant="success")
1396
+ yield Button("Clear (usual tests)", id="pick-clear")
1397
+ yield Button("Cancel", id="pick-cancel")
1398
+
1399
+ def on_mount(self):
1400
+ self.fill("")
1401
+ self.query_one("#pick-tests").focus()
1402
+
1403
+ def row(self, t):
1404
+ on = t["id"] in self.chosen
1405
+ mark = "[b green]\\[✓][/]" if on else "[dim]\\[ ][/dim]"
1406
+ tid = f"[b]{rich_escape(t['id'])}[/b]" if on else rich_escape(t["id"])
1407
+ note = test_note(t)
1408
+ return f"{mark} {diff_badge(t.get('difficulty', 'unrated'))} {tid}" + (f" [dim]{rich_escape(note)}[/dim]" if note else "")
1409
+
1410
+ def fill(self, text):
1411
+ ol = self.query_one("#pick-tests", OptionList)
1412
+ ol.clear_options()
1413
+ text = text.lower()
1414
+ self.shown = [t for t in self.pack.tests
1415
+ if text in " ".join(str(t.get(k, "")) for k in ("id", "description", "category")).lower()]
1416
+ ol.add_options([Option(self.row(t), id=t["id"]) for t in self.shown])
1417
+ if self.shown:
1418
+ ol.highlighted = 0
1419
+ self.count()
1420
+
1421
+ def count(self):
1422
+ shown = f" [dim]({len(self.shown)} shown)[/dim]" if len(self.shown) != len(self.pack.tests) else ""
1423
+ self.query_one("#pick-count", Static).update(
1424
+ (f"[b]{len(self.chosen)}[/b] of {len(self.pack.tests)} ticked" if self.chosen
1425
+ else "[dim]None ticked: the tier's usual tests run.[/dim]") + shown)
1426
+
1427
+ def redraw(self, indexes):
1428
+ ol = self.query_one("#pick-tests", OptionList)
1429
+ for i in indexes:
1430
+ ol.replace_option_prompt_at_index(i, self.row(self.shown[i]))
1431
+ self.count()
1432
+
1433
+ def action_toggle(self):
1434
+ ol = self.query_one("#pick-tests", OptionList)
1435
+ if not ol.has_focus or ol.highlighted is None:
1436
+ return
1437
+ self.chosen.symmetric_difference_update({self.shown[ol.highlighted]["id"]})
1438
+ self.redraw([ol.highlighted])
1439
+
1440
+ @on(OptionList.OptionSelected, "#pick-tests")
1441
+ def selected(self, event): # enter on a row ticks it too
1442
+ self.chosen.symmetric_difference_update({event.option.id})
1443
+ self.redraw([event.option_index])
1444
+
1445
+ def action_toggle_shown(self):
1446
+ """Tick every shown test, or untick them all when they're all ticked."""
1447
+ if self.query_one("#pick-filter", Input).has_focus:
1448
+ return
1449
+ ids = {t["id"] for t in self.shown}
1450
+ if ids <= self.chosen:
1451
+ self.chosen -= ids
1452
+ else:
1453
+ self.chosen |= ids
1454
+ self.redraw(range(len(self.shown)))
1455
+
1456
+ def check_action(self, action, parameters):
1457
+ # while typing in the filter, space and a are text
1458
+ if action in ("toggle", "toggle_shown") and self.query_one("#pick-filter", Input).has_focus:
1459
+ return None
1460
+ return True
1461
+
1462
+ @on(Input.Changed, "#pick-filter")
1463
+ def filter_changed(self, event):
1464
+ self.fill(event.value)
1465
+
1466
+ @on(Input.Submitted, "#pick-filter")
1467
+ def to_list(self):
1468
+ self.query_one("#pick-tests").focus()
1469
+
1470
+ @on(Button.Pressed, "#pick-apply")
1471
+ def apply(self):
1472
+ self.dismiss([t["id"] for t in self.pack.tests if t["id"] in self.chosen])
1473
+
1474
+ @on(Button.Pressed, "#pick-clear")
1475
+ def clear(self):
1476
+ self.dismiss([])
1477
+
1478
+ @on(Button.Pressed, "#pick-cancel")
1479
+ def action_cancel(self):
1480
+ self.dismiss(None)
1481
+
1482
+
1290
1483
  class ModelPickScreen(ModalScreen):
1291
1484
  """Pick which models Per question compares. Dismisses with a set of labels (empty = all) or None."""
1292
1485
  BINDINGS = [Binding("escape", "cancel", "Cancel")]
@@ -2331,7 +2524,7 @@ class EvalsApp(App):
2331
2524
  #dialog { width: 80; height: auto; max-height: 90%; padding: 1 2; border: thick $primary; background: $surface; }
2332
2525
  #dialog.wide { width: 120; }
2333
2526
  #dialog SelectionList, #dialog OptionList { height: auto; max-height: 20; }
2334
- PresetsScreen, ScanScreen, SessionsScreen, ChoiceScreen { align: center middle; }
2527
+ PresetsScreen, ScanScreen, SessionsScreen, ChoiceScreen, PickTestsScreen { align: center middle; }
2335
2528
  #dialog Input, #dialog Select { margin-bottom: 1; }
2336
2529
  .dialog-buttons { height: 3; margin-top: 1; }
2337
2530
  .dialog-buttons Button { margin-right: 2; }
@@ -43,8 +43,8 @@ def free_port():
43
43
 
44
44
 
45
45
  class Mock:
46
- def __init__(self, packs, mode):
47
- self.port = free_port()
46
+ def __init__(self, packs, mode, port=None):
47
+ self.port = port or free_port()
48
48
  self.proc = subprocess.Popen([sys.executable, os.path.join(HERE, "mock_server.py"), "--port", str(self.port),
49
49
  "--packs", packs, "--mode", mode], stdout=subprocess.DEVNULL)
50
50
  for _ in range(100):
@@ -209,6 +209,13 @@ class Units(unittest.TestCase):
209
209
  write(log, "ok\nerror: runtime bootstrap failed [x]: context does not fit\n")
210
210
  self.assertEqual(engine.server_error(log, 0), "context does not fit")
211
211
 
212
+ def test_test_note_skips_what_the_id_says(self):
213
+ from tuieval.tui import test_note
214
+ self.assertEqual(test_note({"id": "normal-entry-1-v2", "category": "normal entry",
215
+ "description": "normal entry #1, compact JSON, shuffled keys"}), "compact JSON, shuffled keys")
216
+ self.assertEqual(test_note({"id": "stale-quote", "description": "stale quote", "category": "data"}), "data")
217
+ self.assertEqual(test_note({"id": "todo", "description": "To-do list app", "category": ""}), "To-do list app")
218
+
212
219
  def test_short_note(self):
213
220
  from tuieval import tui
214
221
  self.assertEqual(tui.short_note("server exited with code 1 while loading: bad file"), "didn't start: bad file")
@@ -225,6 +232,106 @@ class Units(unittest.TestCase):
225
232
  self.assertEqual(p.modules, ["some_missing_module"])
226
233
 
227
234
 
235
+ class PickTests(unittest.TestCase):
236
+ """Running only some tests of a pack: partial runs add up, --force redoes just the picked ones."""
237
+
238
+ def setUp(self):
239
+ self.tmp = tempfile.TemporaryDirectory()
240
+ self.ws = os.path.join(self.tmp.name, "ws")
241
+ tuieval(self.tmp.name, "init", self.ws)
242
+ for name in ("apps", "other"):
243
+ d = os.path.join(self.ws, "packs", name)
244
+ os.makedirs(d)
245
+ write(os.path.join(d, "pack.toml"), f'label = "{name}"\nscreen = 1\n[certify]\nrepeat = 1\n'
246
+ '[gate]\nmin_accuracy = 0.1\n')
247
+ write(os.path.join(d, "tests.yaml"), "".join(
248
+ f"- {{id: {t}, input: {name} question {t}, expected: 1, reference: 'ANSWER: 1', wrong: ['ANSWER: 2'], "
249
+ "difficulty: easy}\n" for t in ("a", "b", "c", "d")))
250
+ self.mock, self.port = None, free_port()
251
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
252
+ f.write(f'\n[servers.m]\nurl = "http://127.0.0.1:{self.port}"\n')
253
+
254
+ def tearDown(self):
255
+ if self.mock:
256
+ self.mock.stop()
257
+ self.tmp.cleanup()
258
+
259
+ def server(self, mode):
260
+ """(Re)start the mock on the same port: oracle answers right, wrong answers wrong."""
261
+ first = self.mock is None
262
+ if self.mock:
263
+ self.mock.stop()
264
+ self.mock = Mock(os.path.join(self.ws, "packs"), mode, port=self.port)
265
+ if first:
266
+ tuieval(self.ws, "add", "mock", "--server", "m", "--label", "m")
267
+
268
+ def rows(self, pack="apps"):
269
+ path = os.path.join(self.ws, "results", "m", f"{pack}.json")
270
+ if not os.path.isfile(path):
271
+ return {}
272
+ return {r["test"]: r["pass"] for r in json.loads(read(path))["results"]}
273
+
274
+ def status(self, pack="apps"):
275
+ code = f"from tuieval import engine; print(engine.Engine().result_status('m', '{pack}'))"
276
+ return subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
277
+ env=dict(os.environ, TUIEVAL_HOME=self.ws)).stdout.strip()
278
+
279
+ def test_pick(self):
280
+ from tuieval import packs
281
+ p = packs.load_pack(os.path.join(self.ws, "packs", "apps"))
282
+ self.assertEqual([t["id"] for t in p.pick(["c", "a"])], ["a", "c"]) # the pack's order
283
+ with self.assertRaises(packs.PackError) as cm:
284
+ p.pick(["a", "zz"])
285
+ self.assertIn("'zz'", str(cm.exception))
286
+ self.assertIn("a, b, c, d", str(cm.exception))
287
+
288
+ def test_partial_runs_add_up_and_force_redoes_only_the_picked(self):
289
+ self.server("oracle")
290
+ tuieval(self.ws, "run", "--packs", "apps", "--tests", "b")
291
+ self.assertEqual(self.rows(), {"b": True})
292
+ self.assertEqual(self.rows("other"), {})
293
+ tuieval(self.ws, "run", "--tests", "apps:a,apps:c", "--tier", "certify") # --packs from --tests
294
+ self.assertEqual(self.rows(), {"a": True, "b": True, "c": True})
295
+ self.assertEqual(self.status(), "screened")
296
+ out = tuieval(self.ws, "run", "--tests", "apps:b").stdout
297
+ self.assertIn("done earlier", out) # nothing missing: not re-asked
298
+ tuieval(self.ws, "run", "--tests", "apps:d", "--tier", "certify")
299
+ self.assertEqual(self.status(), "certified")
300
+
301
+ self.server("wrong")
302
+ tuieval(self.ws, "run", "--tests", "apps:b", "--force")
303
+ self.assertEqual(self.rows(), {"a": True, "b": False, "c": True, "d": True})
304
+ hist = os.listdir(os.path.join(self.ws, "results", "m", "history"))
305
+ self.assertTrue(any(f.startswith("apps-") for f in hist), hist)
306
+ self.assertEqual(self.status(), "certified")
307
+
308
+ def test_cli_errors(self):
309
+ self.server("oracle")
310
+ for args, msg in ((["--tests", "a"], "say which pack"),
311
+ (["--packs", "apps,other", "--tests", "a"], "say which pack"),
312
+ (["--tests", "nope:a"], "unknown pack 'nope'"),
313
+ (["--packs", "apps", "--tests", "zz"], "has no test 'zz'"),
314
+ (["--packs", "other", "--tests", "apps:a"], "aren't in --packs")):
315
+ p = tuieval(self.ws, "run", *args, check=False)
316
+ self.assertNotEqual(p.returncode, 0, args)
317
+ self.assertIn(msg, p.stderr, args)
318
+ self.assertEqual(self.rows(), {})
319
+
320
+ def test_presets_keep_picked_tests(self):
321
+ from tuieval import engine
322
+ path = os.path.join(self.ws, "presets.toml")
323
+ engine.save_preset(path, "quick", ["m"], ["apps", "other"], None, {"apps": ["b", "d"], "gone": ["x"]})
324
+ presets = engine.load_presets(path)
325
+ self.assertEqual(presets["quick"]["tests"], {"apps": ["b", "d"]})
326
+ cfg = {"models": [{"label": "m", "tags": []}]}
327
+ self.assertEqual(engine.resolve_preset(cfg, presets["quick"], ["apps", "other"]),
328
+ (["m"], ["apps", "other"], None, {"apps": ["b", "d"]}))
329
+ self.server("oracle")
330
+ tuieval(self.ws, "run", "--preset", "quick")
331
+ self.assertEqual(self.rows(), {"b": True, "d": True})
332
+ self.assertEqual(len(self.rows("other")), 1) # no pick: the Screen sample (screen = 1)
333
+
334
+
228
335
  def fake_gguf(path, embedding=5120, size=0, kv_heads=4):
229
336
  """A GGUF header with just the keys machines.read_gguf reads, padded to size bytes. kv_heads: a
230
337
  number, or one per layer (a list, as some GGUFs store it)."""
File without changes
File without changes