tuieval 0.2.0.dev77__tar.gz → 0.2.0.dev79__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/writing-packs.md +3 -0
  4. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/engine.py +45 -6
  6. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/packs.py +3 -1
  7. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/run_evals.py +4 -1
  8. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/tui.py +11 -3
  9. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/verdict.py +23 -0
  10. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/tests/test_tuieval.py +46 -0
  11. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/.gitignore +0 -0
  12. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/LICENSE +0 -0
  13. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/README.md +0 -0
  14. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/RELEASING.md +0 -0
  15. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/tui-answer-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/tui-run-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/images/tui-setup.png +0 -0
  33. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/docs/models.md +0 -0
  34. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/builtin.toml +0 -0
  38. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/cli.py +0 -0
  39. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/client.py +0 -0
  40. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/compare.py +0 -0
  41. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/doctor.py +0 -0
  42. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/export.py +0 -0
  43. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/graders/__init__.py +0 -0
  44. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/graders/answer.py +0 -0
  45. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/graders/code.py +0 -0
  46. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/graders/rag.py +0 -0
  47. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/graders/reply.py +0 -0
  48. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/graders/tool_call.py +0 -0
  49. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/logs.py +0 -0
  50. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/machines.py +0 -0
  51. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/profiles.py +0 -0
  53. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/pta.py +0 -0
  54. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev77 → tuieval-0.2.0.dev79}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Early stop: `tuieval run --stop-early`, **Stop a pack once it fails** in Setup, or `stop_early = true` in a pack.toml ends a pack once its FAIL is certain (a critical failure, or an accuracy or truncation bar out of reach), asking first the questions models fail most. Verdicts also say FAIL when a partial run can no longer pass.
5
6
  - `tuieval compare --reference <model>`: every other model paired against one on the questions both answered, per pack and overall: the difference with a 95% CI, better/tie/worse, critical failures and tokens vs the reference. `--packs` limits any compare to some packs.
6
7
  - Fix: a question description or grader reason containing `[` no longer crashes the run screen.
7
8
  - Retest asks per pack before it starts: from zero, continue or leave out. It now really starts from zero, and replaced smoke results go to history/.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev77
3
+ Version: 0.2.0.dev79
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
@@ -34,6 +34,7 @@ needs = ["vision"] # vision | tools | long_context | <python modul
34
34
  order = 30 # position in the list
35
35
  tests = ["tests.yaml"] # optional; default: tests.* first, then every other .yaml/.csv file
36
36
  screen = 20 # tests in a Screen run (spread across categories, one variant per group)
37
+ stop_early = true # stop a run of this pack once its FAIL is certain (default false; see below)
37
38
 
38
39
  [certify]
39
40
  repeat = 3 # repeats in a Certify run (default: models.toml [defaults] repeat)
@@ -48,6 +49,8 @@ min_consistency = 0.95 # share of variant groups where every variant p
48
49
 
49
50
  `group` doubles as the **use case** in the readiness verdicts: all packs in a group must PASS for the use case to PASS. For example, a "Coding" use case could be a `coding` pack plus a `coding-sql` pack.
50
51
 
52
+ `stop_early` suits packs a weak model fails quickly, like a regression set of routine cases: a run stops the pack at the first critical failure, or once the accuracy or truncation bar is out of reach even if every remaining answer passes, saves what it has (the verdict is FAIL) and moves on. It asks first the questions models have failed most often. `tuieval run --stop-early` (or **Stop a pack once it fails** in Setup) does this for every pack in the run. A later Certify fills in the rest.
53
+
51
54
  `needs` entries other than `vision`, `tools` and `long_context` name Python modules the pack's grading needs (for example `pandas`, when your hidden tests use it). The pack is skipped, with a note, where that module isn't installed in tuieval's Python environment.
52
55
 
53
56
  ## Tests (YAML)
@@ -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.dev77'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev77')
21
+ __version__ = version = '0.2.0.dev79'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev79')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -28,6 +28,7 @@ interrupted run resumes from its .partial.jsonl, request by request.
28
28
  Events are delivered as on_event(kind, **data), from the engine thread.
29
29
  """
30
30
  import base64
31
+ import collections
31
32
  import contextlib
32
33
  import dataclasses
33
34
  import fcntl
@@ -438,6 +439,8 @@ class Job:
438
439
  finished: float = 0.0
439
440
  resumed: int = 0 # answers already done when this sitting started
440
441
  earlier: list = dataclasses.field(default_factory=list) # earlier sittings (see Engine._sittings)
442
+ stop_early: bool = False # stop once the pack's FAIL is certain (verdict.fail_certain)
443
+ first: list = dataclasses.field(default_factory=list) # test ids round 1 asks first (most failed)
441
444
 
442
445
  @property
443
446
  def label(self):
@@ -1152,14 +1155,15 @@ class Engine:
1152
1155
  return not current and header_matches(head, pack.fingerprint, settings)
1153
1156
 
1154
1157
  # ---- planning
1155
- def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None):
1158
+ def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None, stop_early=None):
1156
1159
  """Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
1157
1160
  tests). repeat: times each question is asked; None = the tier default (1 for smoke and
1158
1161
  screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
1159
1162
  those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
1160
1163
  matching results are kept and only missing answers are run, unless force; force with
1161
1164
  picked tests reruns those and keeps the pack's other answers. force is True, False, or the
1162
- keys ("label/pack") of the jobs to retest, the others resuming."""
1165
+ keys ("label/pack") of the jobs to retest, the others resuming. stop_early: stop each pack
1166
+ once its FAIL is certain, asking first what models fail most; None = each pack's stop_early."""
1163
1167
  if tier not in TIERS:
1164
1168
  raise ValueError(f"tier must be one of {TIERS}")
1165
1169
  jobs = []
@@ -1173,7 +1177,10 @@ class Engine:
1173
1177
  job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
1174
1178
  self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
1175
1179
  merge=not retest or bool(picked), redo=retest and bool(picked), fresh=retest,
1176
- settings=self.settings(m))
1180
+ settings=self.settings(m),
1181
+ stop_early=pack.stop_early if stop_early is None else bool(stop_early))
1182
+ if job.stop_early:
1183
+ job.first = self.most_failed_first(pack)
1177
1184
  sv = self.serving(m)
1178
1185
  status = self.result_status(label, name, tier)
1179
1186
  job.discards = self._discards(job)
@@ -2005,13 +2012,30 @@ class Engine:
2005
2012
 
2006
2013
  @staticmethod
2007
2014
  def round_order(job, rep):
2008
- """The tests in the order round `rep` asks them: the pack's order first, then a fixed
2009
- shuffle per round (the same on every run, so resuming and reruns ask in the same order)."""
2015
+ """The tests in the order round `rep` asks them: the pack's order first (with an early stop,
2016
+ the most failed first), then a fixed shuffle per round (the same on every run, so resuming
2017
+ and reruns ask in the same order)."""
2010
2018
  tests = list(job.tests)
2011
2019
  if rep:
2012
2020
  random.Random(f"{job.pack.name}:{rep}").shuffle(tests)
2021
+ elif job.first:
2022
+ rank = {tid: i for i, tid in enumerate(job.first)}
2023
+ tests.sort(key=lambda t: rank.get(t["id"], len(rank)))
2013
2024
  return tests
2014
2025
 
2026
+ def most_failed_first(self, pack):
2027
+ """The pack's test ids, the ones models failed most often first (critical failures count
2028
+ double), from every model's stored results; ties keep the pack's order."""
2029
+ fails, seen = collections.Counter(), collections.Counter()
2030
+ for m in self.cfg["models"]:
2031
+ for r in self.records(m["label"], pack.name):
2032
+ if client.server_error_row(r):
2033
+ continue
2034
+ seen[r["test"]] += 1
2035
+ fails[r["test"]] += (not r["pass"]) + (r.get("severity") == "critical")
2036
+ order = {t["id"]: i for i, t in enumerate(pack.tests)}
2037
+ return sorted(order, key=lambda tid: (-(fails[tid] / seen[tid] if seen[tid] else 0), order[tid]))
2038
+
2015
2039
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
2016
2040
  SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "vram_peak_gb", "kernel_share_max", "machine",
2017
2041
  "loaded_alongside")
@@ -2143,7 +2167,10 @@ class Engine:
2143
2167
  # a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
2144
2168
  tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
2145
2169
  warned_reuse = False
2170
+ stopped = self._stop_reason(job, mine)
2146
2171
  for rep in range(job.repeat):
2172
+ if stopped:
2173
+ break
2147
2174
  for test in self.round_order(job, rep):
2148
2175
  if (test["id"], rep) in done_keys:
2149
2176
  continue
@@ -2237,6 +2264,10 @@ class Engine:
2237
2264
  job.passed += bool(g["pass"])
2238
2265
  job.failed += not g["pass"]
2239
2266
  self.emit("request_done", job=job, record=rec)
2267
+ mine.append(rec)
2268
+ stopped = self._stop_reason(job, mine)
2269
+ if stopped:
2270
+ break
2240
2271
  job.status, job.finished = "done", time.time()
2241
2272
  merged = {(r["test"], r["repeat"]): r for r in kept}
2242
2273
  merged.update({(r["test"], r["repeat"]): r for r in new})
@@ -2246,9 +2277,17 @@ class Engine:
2246
2277
  os.remove(partial)
2247
2278
  with contextlib.suppress(OSError):
2248
2279
  os.remove(self._sittings_path(job))
2249
- job.note = f"{job.passed}/{job.done} passed"
2280
+ job.note = f"{job.passed}/{job.done} passed" + (f"; stopped early, FAIL is certain: {stopped}" if stopped else "")
2250
2281
  self.emit("job_done", job=job)
2251
2282
 
2283
+ def _stop_reason(self, job, rows):
2284
+ """Why the job stops early now (verdict.fail_certain), or None."""
2285
+ if not job.stop_early:
2286
+ return None
2287
+ from . import verdict
2288
+ return verdict.fail_certain(job.pack, [r for r in rows if not client.server_error_row(r)],
2289
+ self.cfg["defaults"]["repeat"])
2290
+
2252
2291
  def _partial_to_history(self, job, keep=False):
2253
2292
  """Keep an unfinished run that is set aside in history/; keep: copy it (some of its answers stay)."""
2254
2293
  partial = job.out_path + ".partial.jsonl"
@@ -49,6 +49,7 @@ class Pack:
49
49
  gate: dict # release criteria (see verdict.py)
50
50
  screen: int # tests in a screening run
51
51
  certify_repeat: int # repeats in a certification run (0 = models.toml default)
52
+ stop_early: bool = False # stop the pack once its FAIL is certain (pack.toml stop_early)
52
53
 
53
54
  @property
54
55
  def modules(self):
@@ -221,7 +222,8 @@ def load_pack(path):
221
222
  system=system, needs=list(needs), tools=tools, tests=tests,
222
223
  fingerprint=_fingerprint(system, tools, meta.get("grader", "answer"), tests, images),
223
224
  gate=gate, screen=int(meta.get("screen", 10)),
224
- certify_repeat=int(meta.get("certify", {}).get("repeat", 0)))
225
+ certify_repeat=int(meta.get("certify", {}).get("repeat", 0)),
226
+ stop_early=bool(meta.get("stop_early", False)))
225
227
 
226
228
 
227
229
  def load_packs(packs_dir=None, errors=None):
@@ -745,6 +745,9 @@ def main(argv=None):
745
745
  "pack in --packs, else pack:id (and --packs defaults to those packs)")
746
746
  p.add_argument("--retest", "--force", action="store_true", dest="force",
747
747
  help="ask again even if already answered: the packs, or just the --tests (earlier answers go to history/)")
748
+ p.add_argument("--stop-early", action="store_true",
749
+ help="stop each pack once its FAIL is certain (a critical failure, or a bar out of reach), "
750
+ "asking first what models fail most (default: each pack's stop_early)")
748
751
  p.add_argument("--repeat", type=int, help="times each question is asked, any tier (default: 1 for smoke and "
749
752
  "screen, the pack's certification count for certify)")
750
753
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
@@ -800,7 +803,7 @@ def main(argv=None):
800
803
  if args.tests:
801
804
  tests, pack_names = parse_tests(args.tests, pack_names if (args.packs or args.preset) else None, e.packs)
802
805
  try:
803
- jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests)
806
+ jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests, args.stop_early or None)
804
807
  except packs_mod.PackError as err:
805
808
  sys.exit(str(err))
806
809
 
@@ -390,6 +390,10 @@ picked with e, if any). Without it, answered questions are skipped. Before anyth
390
390
  the packs with earlier answers and lets you choose for each: Zero (ask everything again), Continue
391
391
  (keep what's answered, ask the rest) or Leave out. Replaced answers go to history/, never deleted.
392
392
 
393
+ [b]Stop a pack once it fails[/b]: stop each pack as soon as its FAIL is certain (a critical failure, or
394
+ an accuracy or truncation bar out of reach) and move on, asking first the questions models fail most.
395
+ Packs with stop_early = true in pack.toml always do this. A later Certify fills in the rest.
396
+
393
397
  [b]Keys[/b]
394
398
  s start (with a run going: add to it, or queue it) r results
395
399
  a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
@@ -652,6 +656,8 @@ class SetupScreen(Screen):
652
656
  yield Label("Models at a time")
653
657
  yield Input("", id="parallel", type="integer", max_length=1, placeholder="1")
654
658
  yield Checkbox("Retest (ask again, even if already answered)", id="force")
659
+ with Horizontal(id="options2"):
660
+ yield Checkbox("Stop a pack once it fails (a critical failure, or a bar out of reach)", id="stop-early")
655
661
  yield Static(id="estimate")
656
662
  with Horizontal(id="buttons"):
657
663
  yield Button("Start [s]", id="start", variant="success")
@@ -1009,6 +1015,8 @@ class SetupScreen(Screen):
1009
1015
  self.query_one(f"#tier-{tier}", RadioButton).value = True
1010
1016
  if "force" in sel:
1011
1017
  self.query_one("#force", Checkbox).value = bool(sel["force"])
1018
+ if "stop_early" in sel:
1019
+ self.query_one("#stop-early", Checkbox).value = bool(sel["stop_early"])
1012
1020
  self.query_one("#filter", Input).value = ""
1013
1021
  self.refresh_models()
1014
1022
  if not quiet:
@@ -1043,7 +1051,7 @@ class SetupScreen(Screen):
1043
1051
  return
1044
1052
  picks = self.current_picks(suites)
1045
1053
  sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1046
- "parallel": self.parallel()}
1054
+ "parallel": self.parallel(), "stop_early": self.query_one("#stop-early", Checkbox).value}
1047
1055
  e = self.app.engine
1048
1056
  jobs = e.plan(labels, suites, repeat, tier, True, picks)
1049
1057
  if not any(j.status == "waiting" for j in jobs):
@@ -1573,7 +1581,7 @@ class QueuedRun:
1573
1581
  """(jobs, None), or (None, why it can't run) now."""
1574
1582
  sel = self.sel
1575
1583
  jobs = [j for j in eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], self.retest,
1576
- self.tests(eng)) if j.key not in self.leave]
1584
+ self.tests(eng), sel.get("stop_early") or None) if j.key not in self.leave]
1577
1585
  waiting = [j for j in jobs if j.status == "waiting"]
1578
1586
  if not waiting:
1579
1587
  return None, "every pack left is already done"
@@ -3076,7 +3084,7 @@ class EvalsApp(App):
3076
3084
  #tier-row Label { padding: 1 1 0 0; }
3077
3085
  #tier-row RadioSet { layout: horizontal; width: auto; height: auto; }
3078
3086
  #tier-row RadioButton { width: auto; margin-right: 2; }
3079
- #options { height: 3; padding: 0 1; align-vertical: middle; }
3087
+ #options, #options2 { height: 3; padding: 0 1; align-vertical: middle; }
3080
3088
  #options Label { padding: 1 1 0 0; }
3081
3089
  #options Input { width: 12; }
3082
3090
  #options #parallel { width: 8; }
@@ -73,6 +73,26 @@ def consistency(records):
73
73
  return ok / len(full), len(full)
74
74
 
75
75
 
76
+ def fail_certain(pack, records, repeat_default=3):
77
+ """Why this pack's verdict is FAIL whatever the remaining answers of its certification say,
78
+ or None: a critical failure, or a bar out of reach even if every answer still to come passes
79
+ (accuracy) or none of them is cut off (truncation). What an early stop checks after each answer."""
80
+ crit = next((r for r in records if r.get("severity") == "critical"), None)
81
+ if crit:
82
+ return f"critical failure ({crit['test']})"
83
+ n = len(records)
84
+ total = max(n, len(pack.tests) * (pack.certify_repeat or repeat_default))
85
+ passed = sum(bool(r["pass"]) for r in records)
86
+ bar = pack.gate.get("min_accuracy", 0.8)
87
+ if n and wilson(passed + total - n, total)[1] < bar:
88
+ return (f"{n - passed} wrong of {n}: under the {100 * bar:.0f}% bar even if all "
89
+ f"{total - n} answers left pass")
90
+ cut = sum(r.get("finish") == "length" for r in records)
91
+ if "max_truncation" in pack.gate and cut / total > pack.gate["max_truncation"]:
92
+ return f"{cut} answers cut off by max_tokens: over the {100 * pack.gate['max_truncation']:g}% limit for the pack"
93
+ return None
94
+
95
+
76
96
  def evaluate(pack, records, repeat_default=3, speed_gate=False):
77
97
  """Quality verdict for one model on one pack. `records` are result rows from
78
98
  results/<label>/<pack>.json. The p90 time limit is judged per machine by latency() unless
@@ -112,6 +132,9 @@ def evaluate(pack, records, repeat_default=3, speed_gate=False):
112
132
  if hi < min_acc:
113
133
  fails.append(f"accuracy {100 * passed / n:.1f}% (95% CI {100 * lo:.0f}-{100 * hi:.0f}%) is below "
114
134
  f"the {100 * min_acc:.0f}% bar")
135
+ certain = fail_certain(pack, records, repeat_default)
136
+ if certain and not crit and not fails: # a run stopped early (or a partial one) that can't pass any more
137
+ fails.append(certain)
115
138
  if "min_consistency" in gate and cons is not None and certified and cons < gate["min_consistency"]:
116
139
  fails.append(f"only {100 * cons:.0f}% of {groups} rephrased cases answered consistently "
117
140
  f"(needs {100 * gate['min_consistency']:.0f}%)")
@@ -151,6 +151,52 @@ class Starters(unittest.TestCase):
151
151
  self.assertIn("separate the models", tuieval(self.ws, "compare", "--speed").stdout)
152
152
 
153
153
 
154
+ class EarlyStop(unittest.TestCase):
155
+ """--stop-early ends a pack once its FAIL is certain, and asks what models fail most first."""
156
+
157
+ def test_wrong_model_stops_and_good_model_finishes(self):
158
+ with tempfile.TemporaryDirectory() as tmp:
159
+ ws = os.path.join(tmp, "ws")
160
+ tuieval(tmp, "init", ws)
161
+ tuieval(ws, "new-pack", "demo", "--grader", "answer")
162
+ packs = os.path.join(ws, "packs")
163
+ good, bad = Mock(packs, "oracle"), Mock(packs, "wrong")
164
+ try:
165
+ with open(os.path.join(ws, "models.toml"), "a") as f:
166
+ f.write(f'\n[servers.good]\nurl = "http://127.0.0.1:{good.port}"\n'
167
+ f'\n[servers.bad]\nurl = "http://127.0.0.1:{bad.port}"\n')
168
+ tuieval(ws, "add", "mock", "--server", "good", "--label", "good-mock")
169
+ tuieval(ws, "add", "mock", "--server", "bad", "--label", "bad-mock")
170
+ out = tuieval(ws, "run", "--tier", "certify", "--stop-early").stdout
171
+ finally:
172
+ good.stop()
173
+ bad.stop()
174
+ self.assertIn("bad-mock/demo: done", out)
175
+ self.assertIn("stopped early, FAIL is certain", out)
176
+ bad_res = json.loads(read(os.path.join(ws, "results", "bad-mock", "demo.json")))["results"]
177
+ good_res = json.loads(read(os.path.join(ws, "results", "good-mock", "demo.json")))["results"]
178
+ self.assertEqual(len(good_res), 18)
179
+ self.assertLess(len(bad_res), 18)
180
+ self.assertIn("bad-mock: not ready for", tuieval(ws, "verdict").stdout.split("Details")[0])
181
+ # bad-mock's failures now come first for the next model with an early stop
182
+ code = ("from tuieval import engine; e = engine.Engine(); p = e.packs['demo']; "
183
+ "first = e.most_failed_first(p); fails = {r['test'] for r in e.records('bad-mock', 'demo') "
184
+ "if not r['pass']}; print(set(first[:len(fails)]) == fails)")
185
+ self.assertEqual(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
186
+ env=dict(os.environ, TUIEVAL_HOME=ws)).stdout.strip(), "True")
187
+
188
+ def test_fail_certain(self):
189
+ from tuieval import verdict
190
+ pack = types.SimpleNamespace(tests=[{"id": str(i)} for i in range(10)], certify_repeat=3,
191
+ gate={"min_accuracy": 0.8, "max_truncation": 0.05})
192
+ row = lambda ok, **kw: {"test": "0", "pass": ok, **kw}
193
+ self.assertIsNone(verdict.fail_certain(pack, [row(False)] * 2)) # 28 of 30 could still pass
194
+ self.assertIsNone(verdict.fail_certain(pack, [row(False)] * 9)) # best case 21/30: CI reaches 83%
195
+ self.assertIn("under the 80% bar", verdict.fail_certain(pack, [row(False)] * 12))
196
+ self.assertIn("critical failure", verdict.fail_certain(pack, [row(False, severity="critical")]))
197
+ self.assertIn("cut off", verdict.fail_certain(pack, [row(True, finish="length")] * 2)) # 2 of 30 > 5%
198
+
199
+
154
200
  class WorkspaceGraders(unittest.TestCase):
155
201
  """A workspace's graders/ and a pack's grader.py are loaded, with their critical flag."""
156
202
 
File without changes
File without changes
File without changes