tuieval 0.2.0.dev126__tar.gz → 0.2.0.dev129__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/_version.py +2 -2
  4. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/compare.py +11 -0
  5. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/tui.py +47 -9
  6. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/tests/mock_server.py +6 -1
  7. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/tests/test_tuieval.py +84 -2
  8. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/README.md +0 -0
  11. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/RELEASING.md +0 -0
  12. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/agent-packs.md +0 -0
  13. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/README.md +0 -0
  14. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/favicon.ico +0 -0
  15. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/readme-header.png +0 -0
  16. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/social-preview.png +0 -0
  17. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-1024.png +0 -0
  18. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-128.png +0 -0
  19. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-16.png +0 -0
  20. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-16.svg +0 -0
  21. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-256.png +0 -0
  22. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-32.png +0 -0
  23. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-48.png +0 -0
  24. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-512.png +0 -0
  25. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-64.png +0 -0
  26. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  27. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/brand/tuieval-icon.svg +0 -0
  28. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/tui-answer-reasoning.png +0 -0
  29. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/tui-run-reasoning.png +0 -0
  30. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/images/tui-setup.png +0 -0
  31. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/models.md +0 -0
  32. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/docs/writing-packs.md +0 -0
  33. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/pyproject.toml +0 -0
  34. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/__init__.py +0 -0
  35. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/__main__.py +0 -0
  36. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/agents.py +0 -0
  37. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/builtin.toml +0 -0
  38. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/cli.py +0 -0
  39. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/client.py +0 -0
  40. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/dates.py +0 -0
  41. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/doctor.py +0 -0
  42. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/engine.py +0 -0
  43. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/export.py +0 -0
  44. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/graders/__init__.py +0 -0
  45. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/graders/answer.py +0 -0
  46. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/graders/code.py +0 -0
  47. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/graders/rag.py +0 -0
  48. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/graders/reply.py +0 -0
  49. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/graders/tool_call.py +0 -0
  50. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/insights.py +0 -0
  51. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/logs.py +0 -0
  52. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/machines.py +0 -0
  53. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/onboard.py +0 -0
  54. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/packs.py +0 -0
  55. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/profiles.py +0 -0
  56. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/pta.py +0 -0
  57. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/run_evals.py +0 -0
  59. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/scaffold.py +0 -0
  60. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/selftest.py +0 -0
  61. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/models.toml +0 -0
  62. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  63. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/answer/system.txt +0 -0
  64. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/code/pack.toml +0 -0
  66. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/code/system.txt +0 -0
  67. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  69. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/rag/system.txt +0 -0
  70. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  71. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  72. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/reply/system.txt +0 -0
  73. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  74. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  75. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  76. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  77. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  78. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/tune.py +0 -0
  79. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/verdict.py +0 -0
  80. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/watch_proxy.py +0 -0
  81. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/workspace.py +0 -0
  82. {tuieval-0.2.0.dev126 → tuieval-0.2.0.dev129}/src/tuieval/yamlout.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Results: picking a pack (PTA index or Per question) compares the models that answered all of it; the Models button can change that, and All packs goes back to the earlier pick.
5
6
  - Fix: a request that runs out of time says `time limit: timed out after N s`, not `connection error`, and `tuieval compare` counts it against the model like the run does (it was left out as an error).
6
7
  - Agent packs (`kind = "agent"`): each test is a small project with a task, worked on by a command-line agent you define in `[agents]`, using the model tuieval serves, and graded by the task's visible and hidden tests. Results show the agent's stats, diff and transcript; `tuieval compare` adds an Agent tasks table; `tuieval selftest` checks each task's reference and wrong fixes. See docs/agent-packs.md.
7
8
  - Answers that used more tokens than the `max_tokens` sent (a provider that didn't stop them) are marked over budget: `(n over budget)` in the PTA index, an **over budget** column, `over` in Per question and a note in the answer view. `[defaults] over_budget = "fail"` fails them instead. Works on stored results.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev126
3
+ Version: 0.2.0.dev129
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
@@ -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.dev126'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev126')
21
+ __version__ = version = '0.2.0.dev129'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev129')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -390,6 +390,17 @@ def with_models(test, models):
390
390
  return dict(test, cells=cells, spread=_spread(cells))
391
391
 
392
392
 
393
+ def complete_models(rows, suite, test_ids=None):
394
+ """The models whose rows answer every question of a pack: test_ids (the pack's current tests),
395
+ or every question of it seen in the rows. Partial rows (a pack still running) don't count."""
396
+ rows = [r for r in rows if r["suite"] == suite and not r.get("partial")]
397
+ wanted = {f"{suite}: {t}" for t in test_ids} if test_ids is not None else {r["test"] for r in rows}
398
+ answered = {}
399
+ for r in rows:
400
+ answered.setdefault(r["model"], set()).add(r["test"])
401
+ return sorted(m for m, tests in answered.items() if wanted and wanted <= tests)
402
+
403
+
393
404
  def compare_models(tests, models):
394
405
  """Per model over per_question entries that all `models` answered: passes, token and time
395
406
  totals/medians (totals add each question's median over repeats, so repeats don't inflate them), questions where it used the fewest tokens / was fastest (among passing
@@ -2963,6 +2963,9 @@ class ResultsScreen(Screen):
2963
2963
  self.rows, self.infos, self.partial_rows, self.superseded = [], [], [], set()
2964
2964
  if not hasattr(self.app, "pq_models"):
2965
2965
  self.app.pq_models = set()
2966
+ if getattr(self.app, "pack_pick", None): # the pack lists open on All packs again
2967
+ self.app.pq_models = self.app.pack_pick["before"]
2968
+ self.app.pack_pick = None
2966
2969
  if not hasattr(self.app, "result_sorts"):
2967
2970
  self.app.result_sorts = {} # table id -> (column name, descending), kept for this session
2968
2971
  self.fill()
@@ -3054,7 +3057,7 @@ class ResultsScreen(Screen):
3054
3057
  for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
3055
3058
  when = dates.day(dates.answered_at(self.fail_rows[i].get("raw") or {}))
3056
3059
  f.add_row(model, diff_badge(level), rich_escape(test), when, rich_escape(reason[:130]), key=str(i))
3057
- self.query_one("#fail-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
3060
+ self.query_one("#fail-models", Button).label = self.models_label(chosen)
3058
3061
  n = len(self.fail_rows)
3059
3062
  self.query_one("#fail-count", Static).update(
3060
3063
  f"{n} failure{'s' * (n != 1)}" + (f" of {len(chosen)} model{'s' * (len(chosen) != 1)}" if chosen else ""))
@@ -3072,7 +3075,6 @@ class ResultsScreen(Screen):
3072
3075
  self.app.push_screen(AnswerScreen(keep, keep.index(entries[i]), what="failure"))
3073
3076
 
3074
3077
  # -- per question: tokens and time for every model on the same question
3075
- @on(Select.Changed, "#pq-pack")
3076
3078
  @on(Select.Changed, "#pq-level")
3077
3079
  @on(Checkbox.Changed, "#pq-partial")
3078
3080
  @on(Checkbox.Changed, "#pq-shared")
@@ -3214,7 +3216,7 @@ class ResultsScreen(Screen):
3214
3216
  finally:
3215
3217
  self.filling_insights = False
3216
3218
  chosen = {m for m in self.app.pq_models if m in every}
3217
- self.query_one("#ins-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
3219
+ self.query_one("#ins-models", Button).label = self.models_label(chosen)
3218
3220
  main = os.path.abspath(self.results_dir or e.results_dir) == os.path.abspath(e.results_dir)
3219
3221
  bases = {m["label"]: m["base"] for m in e.cfg["models"] if m.get("base")}
3220
3222
  by = self.query_one("#ins-cost", Select).value or "tokens"
@@ -3222,9 +3224,46 @@ class ResultsScreen(Screen):
3222
3224
  body.update("\n\n".join(insights.render(p, i) for i, p in enumerate(out, 1)))
3223
3225
 
3224
3226
  @on(Select.Changed, "#pta-pack")
3225
- def pta_changed(self):
3226
- if getattr(self, "rows", None) is not None:
3227
- self.fill_pta()
3227
+ @on(Select.Changed, "#pq-pack")
3228
+ def pack_changed(self, event):
3229
+ """A pack picked (PTA index or Per question, kept the same in both): compare the models that
3230
+ answered every question of it (the Models button can change that); All packs: back to the
3231
+ models picked before."""
3232
+ if getattr(self, "rows", None) is None:
3233
+ return
3234
+ e, pack = self.app.engine, event.value or ""
3235
+ for other, values in (("#pta-pack", "pta_pack_values"), ("#pq-pack", "pq_pack_values")):
3236
+ sel = self.query_one(other, Select)
3237
+ if sel is not event.select and sel.value != pack and pack in getattr(self, values, [""]):
3238
+ with sel.prevent(Select.Changed):
3239
+ sel.value = pack
3240
+ shared = self.query_one("#pq-shared", Checkbox)
3241
+ p = self.app.pack_pick or {"before": set(self.app.pq_models), "shared": shared.value}
3242
+ before = p["before"]
3243
+ if not pack:
3244
+ self.app.pack_pick = None
3245
+ with shared.prevent(Checkbox.Changed): # set_models may have ticked it
3246
+ shared.value = p["shared"]
3247
+ self.set_models(before)
3248
+ return
3249
+ rows = [r for r in self.rows if r.get("path") not in self.superseded]
3250
+ complete = set(compare.complete_models(rows, pack, [t["id"] for t in e.packs[pack].tests]
3251
+ if pack in e.packs else None))
3252
+ self.app.pack_pick = {"pack": pack, "models": complete, "before": before, "shared": p["shared"]}
3253
+ if not complete:
3254
+ self.notify(f"No model has answered every question of {self.pack_label(pack)} yet: "
3255
+ "the models picked before are kept.", severity="warning", timeout=8)
3256
+ self.set_models(complete or before)
3257
+
3258
+ def pack_label(self, pack):
3259
+ return self.app.engine.packs[pack].label if pack in self.app.engine.packs else pack
3260
+
3261
+ def models_label(self, chosen):
3262
+ """The Models buttons' text: the models a pack pick chose say so."""
3263
+ p = self.app.pack_pick
3264
+ if chosen and p and chosen == p["models"]:
3265
+ return f"Models: {len(chosen)} complete on {self.pack_label(p['pack'])}"
3266
+ return f"Models: {len(chosen)} picked" if chosen else "Models: all"
3228
3267
 
3229
3268
  def fill_pta(self):
3230
3269
  """The PTA index (pta.py) of the picked models (Models button, shared with Per question) on
@@ -3245,7 +3284,7 @@ class ResultsScreen(Screen):
3245
3284
  rows = [r for r in rows if r["suite"] == sel.value]
3246
3285
  present = {r["model"] for r in rows}
3247
3286
  chosen = {m for m in self.app.pq_models if m in present}
3248
- self.query_one("#pta-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
3287
+ self.query_one("#pta-models", Button).label = self.models_label(chosen)
3249
3288
  t = self.query_one("#pta", DataTable)
3250
3289
  t.clear(columns=True)
3251
3290
  if not present:
@@ -3300,8 +3339,7 @@ class ResultsScreen(Screen):
3300
3339
  all_tests = compare.per_question(rows)
3301
3340
  self.pq_all_tests = all_tests
3302
3341
  chosen = {m for m in self.app.pq_models if any(m in t["cells"] for t in all_tests)}
3303
- self.query_one("#pq-models", Button).label = (
3304
- f"Models: {len(chosen)} picked" if chosen else "Models: all")
3342
+ self.query_one("#pq-models", Button).label = self.models_label(chosen)
3305
3343
  tests = []
3306
3344
  for t in all_tests:
3307
3345
  if (pack and t["suite"] != pack) or (level and t["difficulty"] != level):
@@ -56,6 +56,7 @@ class Handler(BaseHTTPRequestHandler):
56
56
  answers, mode, model, delay, max_output = {}, "oracle", "mock", 0.0, None
57
57
  stall, stall_before, silent = 0.0, 0.0, False
58
58
  die_after = None
59
+ hold, answered = None, 0 # while the hold file exists, answers after the first wait (a run that stays going)
59
60
 
60
61
  def log_message(self, *a):
61
62
  pass
@@ -120,6 +121,8 @@ class Handler(BaseHTTPRequestHandler):
120
121
  self.wfile.flush()
121
122
  time.sleep(0.2)
122
123
 
124
+ while self.hold and Handler.answered and os.path.exists(self.hold):
125
+ wait(0.2)
123
126
  wait(self.stall_before)
124
127
  send({"choices": [{"delta": {"reasoning_content": "Thinking it over. "}}]})
125
128
  wait(self.stall)
@@ -136,6 +139,7 @@ class Handler(BaseHTTPRequestHandler):
136
139
  "prompt_per_second": 500.0, "cache_n": 0}})
137
140
  die_if_due()
138
141
  self.wfile.write(b"data: [DONE]\n\n")
142
+ Handler.answered += 1
139
143
 
140
144
 
141
145
  class Server(ThreadingHTTPServer):
@@ -157,11 +161,12 @@ def main(argv=None):
157
161
  p.add_argument("--stall-before", type=float, default=0.0)
158
162
  p.add_argument("--silent", action="store_true")
159
163
  p.add_argument("--die-after", type=int)
164
+ p.add_argument("--hold", help="while this file exists, every answer after the first waits")
160
165
  a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
161
166
  Handler.answers, Handler.mode, Handler.model, Handler.delay = load_answers(a.packs), a.mode, a.model, a.delay
162
167
  Handler.max_output = a.max_output
163
168
  Handler.stall, Handler.stall_before, Handler.silent = a.stall, a.stall_before, a.silent
164
- Handler.die_after = a.die_after
169
+ Handler.die_after, Handler.hold = a.die_after, a.hold
165
170
  srv = Server(("127.0.0.1", a.port), Handler)
166
171
  print(f"mock server on 127.0.0.1:{a.port} ({a.mode}, {len(Handler.answers)} known questions)", flush=True)
167
172
  try:
@@ -1412,8 +1412,12 @@ class AddToRun(unittest.TestCase):
1412
1412
 
1413
1413
  def test_tui_never_queues_the_same_thing_twice(self):
1414
1414
  """Start while a run is going never adds or queues a model and pack already running or queued."""
1415
+ hold = os.path.join(self.tmp.name, "hold") # the first run stays going until every pick is made
1416
+ write(hold, "")
1417
+ path = os.path.join(self.ws, "models.toml")
1418
+ write(path, read(path).replace('"--delay", "0.2"', f'"--delay", "0.2", "--hold", {json.dumps(hold)}'))
1415
1419
  code = textwrap.dedent("""
1416
- import asyncio, json, time
1420
+ import asyncio, json, os, time
1417
1421
  from tuieval.tui import EvalsApp, RunScreen, SetupScreen, ChoiceScreen
1418
1422
  from textual.widgets import SelectionList
1419
1423
  async def pick(app, pilot, models, packs=("apps",)):
@@ -1459,6 +1463,7 @@ class AddToRun(unittest.TestCase):
1459
1463
  if isinstance(s, ChoiceScreen):
1460
1464
  s.dismiss("queue")
1461
1465
  await pilot.pause()
1466
+ os.remove(os.environ["HOLD"])
1462
1467
  deadline = time.time() + 300 # the queued runs start one after another: wait for all three
1463
1468
  while time.time() < deadline:
1464
1469
  await pilot.pause(0.2)
@@ -1469,7 +1474,7 @@ class AddToRun(unittest.TestCase):
1469
1474
  asyncio.run(go())
1470
1475
  """)
1471
1476
  p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1472
- env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
1477
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS="", HOLD=hold))
1473
1478
  out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1474
1479
  self.assertIsInstance(out, dict, out)
1475
1480
  self.assertEqual(out["same"], [False, 0])
@@ -1651,6 +1656,83 @@ class PTAIndex(unittest.TestCase):
1651
1656
  self.assertEqual(sorted(cells, key=sort_key), ["10/20", "3/4", "4/4", "abc", "-"])
1652
1657
  self.assertEqual(sorted(cells, key=lambda c: sort_key(c, True)), ["4/4", "3/4", "10/20", "abc", "-"])
1653
1658
 
1659
+ def test_complete_models(self):
1660
+ from tuieval import compare
1661
+ rows = [{"model": m, "suite": s, "test": f"{s}: {t}", **({"partial": True} if m == "running" else {})}
1662
+ for m, s, ts in (("full", "p", "abc"), ("half", "p", "ab"), ("running", "p", "abc"), ("full", "o", "a"))
1663
+ for t in ts]
1664
+ self.assertEqual(compare.complete_models(rows, "p", ["a", "b", "c"]), ["full"]) # half misses c; running is ◐
1665
+ self.assertEqual(compare.complete_models(rows, "p", ["a", "b"]), ["full", "half"])
1666
+ self.assertEqual(compare.complete_models(rows, "p", ["a", "b", "c", "d"]), []) # a question added since
1667
+ self.assertEqual(compare.complete_models(rows, "p"), ["full"]) # pack gone: questions seen
1668
+ self.assertEqual(compare.complete_models(rows, "x", []), [])
1669
+
1670
+ def test_results_pack_picks_the_models_that_answered_all_of_it(self):
1671
+ with tempfile.TemporaryDirectory() as tmp:
1672
+ ws = os.path.join(tmp, "ws")
1673
+ tuieval(tmp, "init", ws)
1674
+ for name, ids in (("apps", "abcd"), ("other", "ab")):
1675
+ d = os.path.join(ws, "packs", name)
1676
+ os.makedirs(d)
1677
+ write(os.path.join(d, "pack.toml"), f'label = "{name.title()}"\n[certify]\nrepeat = 1\n')
1678
+ write(os.path.join(d, "tests.yaml"), "".join(
1679
+ f"- {{id: {t}, input: question {t}, expected: 1, reference: 'ANSWER: 1', wrong: ['ANSWER: 2'], "
1680
+ "difficulty: easy}\n" for t in ids))
1681
+ mock = Mock(os.path.join(ws, "packs"), "oracle")
1682
+ try:
1683
+ with open(os.path.join(ws, "models.toml"), "a") as f:
1684
+ f.write(f'\n[servers.here]\nurl = "http://127.0.0.1:{mock.port}"\n')
1685
+ for label in ("full", "half", "solo"):
1686
+ tuieval(ws, "add", "mock", "--server", "here", "--label", label)
1687
+ tuieval(ws, "run", "--tier", "certify", "--only", "full")
1688
+ tuieval(ws, "run", "--tier", "certify", "--only", "half", "--packs", "apps", "--tests", "a,b")
1689
+ tuieval(ws, "run", "--tier", "certify", "--only", "solo", "--packs", "other")
1690
+ finally:
1691
+ mock.stop()
1692
+ code = textwrap.dedent("""
1693
+ import asyncio
1694
+ from tuieval.tui import EvalsApp, ResultsScreen
1695
+ from textual.widgets import Button, DataTable, Select
1696
+ async def go():
1697
+ app = EvalsApp({})
1698
+ async with app.run_test(size=(180, 50)) as pilot:
1699
+ await pilot.pause()
1700
+ app.pq_models = {"half"} # picked by hand before
1701
+ app.push_screen(ResultsScreen())
1702
+ await pilot.pause(0.5)
1703
+ s = app.screen
1704
+ def state():
1705
+ pq = s.query_one("#per-question", DataTable)
1706
+ cols = [str(c.label) for c in pq.ordered_columns][len(s.PQ_FIXED):]
1707
+ return (sorted(app.pq_models), s.query_one("#pta-pack", Select).value,
1708
+ str(s.query_one("#pq-models", Button).label), cols, s.query_one("#pq-shared").value)
1709
+ s.query_one("#pq-pack", Select).value = "apps"
1710
+ await pilot.pause(0.3)
1711
+ print(state())
1712
+ s.query_one("#pta-pack", Select).value = "other"
1713
+ await pilot.pause(0.3)
1714
+ print(state())
1715
+ s.query_one("#pta-pack", Select).value = ""
1716
+ await pilot.pause(0.3)
1717
+ print(state())
1718
+ app.pq_models = set() # none picked: All packs gives every model again
1719
+ s.set_models(set())
1720
+ await pilot.pause(0.3)
1721
+ for pack in ("apps", ""):
1722
+ s.query_one("#pq-pack", Select).value = pack
1723
+ await pilot.pause(0.3)
1724
+ print(state())
1725
+ asyncio.run(go())
1726
+ """)
1727
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
1728
+ env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
1729
+ self.assertEqual(out.stdout.strip().splitlines(), [
1730
+ "(['full'], 'apps', 'Models: 1 complete on Apps', ['full'], False)",
1731
+ "(['full', 'solo'], 'other', 'Models: 2 complete on Other', ['full', 'solo'], False)",
1732
+ "(['half'], '', 'Models: 1 picked', ['half'], False)",
1733
+ "(['full'], 'apps', 'Models: 1 complete on Apps', ['full'], True)", # none picked before: ticked
1734
+ "([], '', 'Models: all', ['full', 'half', 'solo'], False)"], out.stderr[-2000:])
1735
+
1654
1736
  def test_cli_report_and_tui(self):
1655
1737
  with tempfile.TemporaryDirectory() as tmp:
1656
1738
  ws = os.path.join(tmp, "ws")
File without changes
File without changes