tuieval 0.2.0.dev75__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.dev75 → tuieval-0.2.0.dev79}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/PKG-INFO +3 -1
  3. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/README.md +2 -0
  4. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/writing-packs.md +3 -0
  5. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/_version.py +2 -2
  6. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/cli.py +1 -1
  7. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/compare.py +58 -1
  8. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/engine.py +45 -6
  9. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/packs.py +3 -1
  10. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/run_evals.py +4 -1
  11. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/tui.py +11 -3
  12. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/verdict.py +23 -0
  13. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/tests/test_tuieval.py +69 -0
  14. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/.gitignore +0 -0
  15. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/LICENSE +0 -0
  16. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/RELEASING.md +0 -0
  17. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/README.md +0 -0
  18. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/favicon.ico +0 -0
  19. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/readme-header.png +0 -0
  20. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/social-preview.png +0 -0
  21. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-1024.png +0 -0
  22. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-128.png +0 -0
  23. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-16.png +0 -0
  24. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-16.svg +0 -0
  25. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-256.png +0 -0
  26. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-32.png +0 -0
  27. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-48.png +0 -0
  28. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-512.png +0 -0
  29. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-64.png +0 -0
  30. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  31. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/brand/tuieval-icon.svg +0 -0
  32. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/tui-answer-reasoning.png +0 -0
  33. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/tui-run-reasoning.png +0 -0
  34. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/images/tui-setup.png +0 -0
  35. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/docs/models.md +0 -0
  36. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/pyproject.toml +0 -0
  37. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/__init__.py +0 -0
  38. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/__main__.py +0 -0
  39. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/builtin.toml +0 -0
  40. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/client.py +0 -0
  41. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/doctor.py +0 -0
  42. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/export.py +0 -0
  43. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/graders/__init__.py +0 -0
  44. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/graders/answer.py +0 -0
  45. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/graders/code.py +0 -0
  46. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/graders/rag.py +0 -0
  47. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/graders/reply.py +0 -0
  48. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/graders/tool_call.py +0 -0
  49. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/logs.py +0 -0
  50. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/machines.py +0 -0
  51. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/profiles.py +0 -0
  53. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/pta.py +0 -0
  54. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev75 → tuieval-0.2.0.dev79}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
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.
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.
5
7
  - Fix: a question description or grader reason containing `[` no longer crashes the run screen.
6
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/.
7
9
  - Retest: the setup checkbox "Rerun existing results" is now "Retest (ask again, even if already answered)", and `tuieval run --retest` is the same (`--force` still works).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev75
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
@@ -160,6 +160,7 @@ tuieval verdict # PASS / FAIL / INCONCLUSIVE per m
160
160
  tuieval report # the same with evidence, as a markdown file
161
161
  tuieval history # every verdict change over time
162
162
  tuieval compare --speed --pairwise --failures # scorecards
163
+ tuieval compare --reference <model> --packs X # each model vs one: better, tie or worse
163
164
  tuieval selftest # check every test's reference and wrong answers
164
165
  tuieval items # tests that don't separate models or look broken
165
166
  tuieval logs # follow the server log of the model running now
@@ -179,6 +180,7 @@ tuieval help
179
180
  - **Start with Production readiness.** A single critical failure already means FAIL, whatever the accuracy: a model that breaks a hard rule once in 300 answers will do it in production. The Failures tab and `tuieval report` list exactly which answers failed.
180
181
  - **INCONCLUSIVE is not "nearly passed".** It says what's missing (usually a Certify run, or more trials).
181
182
  - **Trust `tuieval compare --pairwise` over raw percentages.** Differences of one or two tests are usually noise.
183
+ - **Comparing variants of one model (fine-tunes, quantizations)?** `tuieval compare --reference <base>` pairs each against the base on the same questions and says better, tie or worse. Keep the server, quantization and settings the same, or they'll show up as differences too.
182
184
  - **Read `trunc` before accuracy.** A model that runs out of tokens isn't wrong; it's thinking too long for the budget.
183
185
  - **Watch TTFT for interactive use.** A model that is 5% more accurate but takes 3 s longer to start answering may be the worse choice.
184
186
 
@@ -134,6 +134,7 @@ tuieval verdict # PASS / FAIL / INCONCLUSIVE per m
134
134
  tuieval report # the same with evidence, as a markdown file
135
135
  tuieval history # every verdict change over time
136
136
  tuieval compare --speed --pairwise --failures # scorecards
137
+ tuieval compare --reference <model> --packs X # each model vs one: better, tie or worse
137
138
  tuieval selftest # check every test's reference and wrong answers
138
139
  tuieval items # tests that don't separate models or look broken
139
140
  tuieval logs # follow the server log of the model running now
@@ -153,6 +154,7 @@ tuieval help
153
154
  - **Start with Production readiness.** A single critical failure already means FAIL, whatever the accuracy: a model that breaks a hard rule once in 300 answers will do it in production. The Failures tab and `tuieval report` list exactly which answers failed.
154
155
  - **INCONCLUSIVE is not "nearly passed".** It says what's missing (usually a Certify run, or more trials).
155
156
  - **Trust `tuieval compare --pairwise` over raw percentages.** Differences of one or two tests are usually noise.
157
+ - **Comparing variants of one model (fine-tunes, quantizations)?** `tuieval compare --reference <base>` pairs each against the base on the same questions and says better, tie or worse. Keep the server, quantization and settings the same, or they'll show up as differences too.
156
158
  - **Read `trunc` before accuracy.** A model that runs out of tokens isn't wrong; it's thinking too long for the budget.
157
159
  - **Watch TTFT for interactive use.** A model that is 5% more accurate but takes 3 s longer to start answering may be the worse choice.
158
160
 
@@ -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.dev75'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev75')
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
@@ -13,7 +13,7 @@ Start here
13
13
  tuieval verdict which model is ready for what, and what to run next
14
14
 
15
15
  Day to day
16
- tuieval compare [...] scorecards (--speed, --pairwise, --failures)
16
+ tuieval compare [...] scorecards (--speed, --pairwise, --reference M, --failures)
17
17
  tuieval pta the PTA index: parsimony (tokens), time and accuracy per model, as bars
18
18
  tuieval report the verdicts with their evidence, as a markdown file
19
19
  tuieval history every verdict change, newest first
@@ -5,6 +5,8 @@ Usage:
5
5
  tuieval compare --speed # speed & tokens table (TTFT, tok/s, tokens, memory)
6
6
  tuieval compare --machine m3max-64gb # speed on another machine (measured there, or projected)
7
7
  tuieval compare --pairwise --failures # is each difference real? which tests failed?
8
+ tuieval compare --reference MODEL # every model paired against one: better, tie or worse
9
+ tuieval compare --packs coding,rag # only these packs (works with every option)
8
10
  tuieval compare results/some-model/*.json # specific files
9
11
  """
10
12
  import collections
@@ -409,6 +411,49 @@ def pairwise(rows, rng=None):
409
411
  return out
410
412
 
411
413
 
414
+ def vs_reference(rows, ref, rng=None):
415
+ """Every other model against one reference model, paired on the questions both answered:
416
+ [{model, pack ("all" = every pack together), n, diff, lo, hi, verdict, crit, ref_crit, tokens}].
417
+
418
+ diff is the mean over shared questions of (pass rate - the reference's pass rate), with a 95%
419
+ bootstrap CI; verdict is "better" or "worse" when the CI excludes 0, else "tie". crit/ref_crit
420
+ count critical failures on those questions; tokens is the median of per-question token ratios
421
+ (median tokens over repeats, model / reference), so 0.5 means half the tokens."""
422
+ rng = rng or random.Random(0)
423
+ by = collections.defaultdict(lambda: collections.defaultdict(list)) # model -> test -> rows
424
+ suite = {}
425
+ for r in rows:
426
+ by[r["model"]][r["test"]].append(r)
427
+ suite[r["test"]] = r["suite"]
428
+ if ref not in by:
429
+ return []
430
+ out = []
431
+ for m in sorted(set(by) - {ref}):
432
+ shared = sorted(set(by[m]) & set(by[ref]))
433
+ for pack in sorted({suite[t] for t in shared}) + ["all"]:
434
+ tests = [t for t in shared if pack in ("all", suite[t])]
435
+ if not tests:
436
+ continue
437
+ rate = lambda model, t: statistics.fmean(r["ok"] for r in by[model][t])
438
+ diffs = [rate(m, t) - rate(ref, t) for t in tests]
439
+ lo, hi = bootstrap_ci(diffs, rng)
440
+ crit = lambda model: sum(r.get("severity") == "critical" for t in tests for r in by[model][t])
441
+ ratios = [a / b for t in tests
442
+ if (a := median(r["tokens"] for r in by[m][t])) and (b := median(r["tokens"] for r in by[ref][t]))]
443
+ out.append({"model": m, "pack": pack, "n": len(tests), "diff": statistics.fmean(diffs), "lo": lo, "hi": hi,
444
+ "verdict": "better" if lo > 0 else "worse" if hi < 0 else "tie",
445
+ "crit": crit(m), "ref_crit": crit(ref), "tokens": median(ratios)})
446
+ return out
447
+
448
+
449
+ def vs_reference_table(results, ref):
450
+ header = ["model", "pack", "questions", f"vs {ref}", "95% CI", "verdict", "critical (vs ref)", "tokens vs ref"]
451
+ table = [[x["model"], x["pack"], x["n"], f"{100 * x['diff']:+.1f} pts", f"{100 * x['lo']:+.1f} to {100 * x['hi']:+.1f}",
452
+ x["verdict"].upper() if x["pack"] == "all" else x["verdict"], f"{x['crit']} ({x['ref_crit']})",
453
+ _fmt(x["tokens"], ".2f") + ("×" if x["tokens"] is not None else "")] for x in results]
454
+ return header, table
455
+
456
+
412
457
  def failures(rows):
413
458
  return [(r["model"], r.get("name", r["test"]), ("TRUNCATED " if r["truncated"] and not r["reason"].startswith("TRUNCATED")
414
459
  else "") + r["reason"], r.get("difficulty", "unrated"))
@@ -499,10 +544,22 @@ def main(argv):
499
544
  flags = {a for a in argv if a.startswith("--")}
500
545
  log_dir = argv[argv.index("--logs") + 1] if "--logs" in argv else None
501
546
  machine_arg = argv[argv.index("--machine") + 1] if "--machine" in argv else None
502
- paths = [a for a in argv if not a.startswith("--") and a not in (log_dir, machine_arg)] or default_paths()
547
+ ref = argv[argv.index("--reference") + 1] if "--reference" in argv else None
548
+ only_packs = argv[argv.index("--packs") + 1].split(",") if "--packs" in argv else None
549
+ values = (log_dir, machine_arg, ref, ",".join(only_packs) if only_packs else None)
550
+ paths = [a for a in argv if not a.startswith("--") and a not in values] or default_paths()
551
+ if only_packs:
552
+ paths = [p for p in paths if os.path.basename(p)[:-len(".json")] in only_packs]
503
553
  if not paths:
504
554
  sys.exit(__doc__ + "\n(no results found in results/<model>/<pack>.json: run some evals first)")
505
555
  rows, infos = load(paths)
556
+ if ref:
557
+ if ref not in {r["model"] for r in rows}:
558
+ sys.exit(f"no results for reference model {ref!r}" + (f" in {', '.join(only_packs)}" if only_packs else ""))
559
+ print(f"Paired against {ref} on the questions both answered (pass rate per question, repeats averaged;"
560
+ "\n95% bootstrap CI over questions; BETTER/WORSE only when the CI excludes 0):")
561
+ print_table(*vs_reference_table(vs_reference(rows, ref, random.Random(0)), ref))
562
+ return
506
563
  rng = random.Random(0)
507
564
  from . import packs as packs_mod
508
565
  packs_mod.load_packs(errors=[]) # loads the workspace's graders, and their Scorecard columns
@@ -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
 
@@ -209,6 +255,29 @@ class Units(unittest.TestCase):
209
255
  self.assertEqual(compare.settings_notes(infos),
210
256
  ["Settings differ: effort medium on 4 models; penalty 1.5 on j."])
211
257
 
258
+ def test_vs_reference_pairs_on_shared_questions(self):
259
+ from tuieval import compare
260
+ row = lambda m, pack, t, ok, tokens=100, sev=None, rep=0: {
261
+ "model": m, "suite": pack, "test": f"{pack}: {t}", "ok": ok, "tokens": tokens, "severity": sev, "repeat": rep}
262
+ rows = [row("ref", "a", f"q{i}", i < 10) for i in range(20)] # ref: 10/20 on a
263
+ rows += [row("good", "a", f"q{i}", True, tokens=50) for i in range(20)] # good: 20/20, half the tokens
264
+ rows += [row("same", "a", f"q{i}", i < 10) for i in range(20)]
265
+ rows += [row("bad", "a", f"q{i}", False, sev="critical" if i == 0 else None) for i in range(20)]
266
+ rows += [row("ref", "b", "x", True), row("good", "b", "x", False), row("good", "b", "x", True, rep=1)]
267
+ rows += [row("good", "b", "only-good", True)] # not shared: ignored
268
+ res = {(x["model"], x["pack"]): x for x in compare.vs_reference(rows, "ref")}
269
+ self.assertEqual(res["good", "a"]["verdict"], "better")
270
+ self.assertAlmostEqual(res["good", "a"]["diff"], 0.5)
271
+ self.assertEqual(res["good", "a"]["tokens"], 0.5)
272
+ self.assertEqual(res["same", "a"]["verdict"], "tie")
273
+ self.assertEqual((res["bad", "a"]["verdict"], res["bad", "a"]["crit"], res["bad", "a"]["ref_crit"]), ("worse", 1, 0))
274
+ self.assertEqual(res["good", "b"]["n"], 1)
275
+ self.assertAlmostEqual(res["good", "b"]["diff"], -0.5) # repeats averaged
276
+ self.assertEqual(res["good", "all"]["n"], 21)
277
+ self.assertEqual(compare.vs_reference(rows, "nobody"), [])
278
+ header, table = compare.vs_reference_table([res["good", "all"]], "ref")
279
+ self.assertEqual(table[0][5], "BETTER")
280
+
212
281
  def test_eta_goes_pack_by_pack(self):
213
282
  from tuieval.tui import RunScreen
214
283
  job = lambda key, label, total, done=0, status="waiting": types.SimpleNamespace(
File without changes
File without changes