tuieval 0.2.0.dev139__tar.gz → 0.2.0.dev143__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.dev139 → tuieval-0.2.0.dev143}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/models.md +1 -1
  4. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/compare.py +3 -1
  6. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/pta.py +72 -41
  7. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/run_evals.py +5 -1
  8. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/tui.py +23 -6
  9. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/tests/test_tuieval.py +74 -2
  10. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/.gitignore +0 -0
  11. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/LICENSE +0 -0
  12. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/README.md +0 -0
  13. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/RELEASING.md +0 -0
  14. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/agent-packs.md +0 -0
  15. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/tui-answer-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/tui-run-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/images/tui-setup.png +0 -0
  33. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/docs/writing-packs.md +0 -0
  34. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/agents.py +0 -0
  38. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/builtin.toml +0 -0
  39. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/cli.py +0 -0
  40. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/client.py +0 -0
  41. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/dates.py +0 -0
  42. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/doctor.py +0 -0
  43. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/engine.py +0 -0
  44. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/export.py +0 -0
  45. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/graders/__init__.py +0 -0
  46. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/graders/answer.py +0 -0
  47. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/graders/code.py +0 -0
  48. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/graders/rag.py +0 -0
  49. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/graders/reply.py +0 -0
  50. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/graders/tool_call.py +0 -0
  51. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/insights.py +0 -0
  52. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/logs.py +0 -0
  53. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/machines.py +0 -0
  54. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/onboard.py +0 -0
  55. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/packs.py +0 -0
  56. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/profiles.py +0 -0
  57. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev139 → tuieval-0.2.0.dev143}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - PTA index: models scoring below 85% are left out by default (listed with their score), unless every model is. The **Score ≥** dropdown (`tuieval pta --min-score N`) sets the minimum, Any score shows all; picked models are always shown.
6
+ - Per question → Comparison: a **Pass rate** column beside Passed, the share of answers right with repeats included (shown when some model has repeats).
5
7
  - Repeats (Setup, `tuieval run --repeat N`) add N rounds to the saved answers: ×1 run with 2 gives ×3, not ×2. Retest, a blank count and presets still mean the total.
6
8
  - Answers stuck in a loop (one short unit repeated over 2000 chars, in the reasoning or the answer) are stopped as they stream and failed as `LOOP`, instead of running to max_tokens. `[defaults] loop_stop_chars` sets the length; 0 turns it off.
7
9
  - Repeats: the PTA index, Per question, `tuieval compare` and Setup show how many times each model answered each question (`×3`, `×1-3`), so single-run results stand out.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev139
3
+ Version: 0.2.0.dev143
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
@@ -111,7 +111,7 @@ The **PTA index** puts parsimony (tokens), time and accuracy side by side, as a
111
111
 
112
112
  The P and T bars are as long as the tokens and time used: the model that used the most fills its bar, the others in proportion (at least one block), so **shorter is better**. The A bar is the share of answers right, so **longer is better**; it's full only when every answer passed. Each heading says which way is better.
113
113
 
114
- Models are compared on the questions all of them answered, so one that skipped some never looks faster. Server and connection errors aren't answers: a model with only those is listed as having none. A model with fewer than half as many answers as the most-answered one is left out (and listed), or it would shrink the shared questions for everyone. Pick the models and pack to compare in the TUI, or use `--only` and `--packs`: models you pick are always compared.
114
+ Models are compared on the questions all of them answered, so one that skipped some never looks faster. Server and connection errors aren't answers: a model with only those is listed as having none. A model with fewer than half as many answers as the most-answered one is left out (and listed), or it would shrink the shared questions for everyone. So is one scoring below 85% (listed with its score), unless every model does: the rest are then compared again, on the questions they all answered. The **Score ≥** dropdown in the tab (or `tuieval pta --min-score N`) sets that minimum; **Any score** (`0`) shows every model. Pick the models and pack to compare in the TUI, or use `--only` and `--packs`: models you pick are always compared.
115
115
 
116
116
  ## Insights
117
117
 
@@ -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.dev139'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev139')
21
+ __version__ = version = '0.2.0.dev143'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev143')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -420,7 +420,8 @@ def complete_models(rows, suite, test_ids=None):
420
420
  def compare_models(tests, models):
421
421
  """Per model over per_question entries that all `models` answered: passes, token and time
422
422
  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
423
- answers for tokens; ties count for each), answers cut off, mean score (partial credit)."""
423
+ answers for tokens; ties count for each), answers cut off, mean score (partial credit),
424
+ pass rate (answers right with repeats, each question weighing the same)."""
424
425
  out = []
425
426
  for m in models:
426
427
  cs = [t["cells"][m] for t in tests]
@@ -438,6 +439,7 @@ def compare_models(tests, models):
438
439
  rs = [r for c in cs for r in c["rows"]]
439
440
  out.append({"model": m, "n": len(cs), "passed": passed, "total_tokens": total,
440
441
  "score": statistics.fmean(c["score"] for c in cs) if cs else None,
442
+ "pass_rate": statistics.fmean(c["passed"] / c["n"] for c in cs) if cs else None,
441
443
  "avg_pp_tps": _rate([(r.get("prompt_tokens"), r["prompt_tps"]) for r in rs]),
442
444
  "avg_gen_tps": _rate([(r["tokens"], r["gen_tps"]) for r in rs]),
443
445
  "med_tokens": median(c["tokens"] for c in cs), "med_secs": median(c["secs"] for c in cs),
@@ -21,7 +21,8 @@ it varies). A ×1 model's accuracy rests on one answer per question, so it is ro
21
21
  Models are compared on the questions all of them answered, so one that skipped some never looks
22
22
  faster. Server and connection errors aren't answers. By default a model with fewer than half as many
23
23
  answers as the most-answered model is left out (listed, so it can be picked), or it would shrink the
24
- shared questions for everyone. There's no combined number: the three stay side by side, and verdicts
24
+ shared questions for everyone, and so is one that scores below MIN_SCORE (also listed), unless every
25
+ model does. There's no combined number: the three stay side by side, and verdicts
25
26
  and critical failures stay where they are (verdict.py).
26
27
  """
27
28
  import math
@@ -31,6 +32,8 @@ from rich.markup import escape as rich_escape
31
32
  from . import compare
32
33
 
33
34
  MIN_SHARE = 0.5 # fewer answers than this share of the most-answered model's: left out by default
35
+ MIN_SCORE = 85 # a score (partial credit, %) below this: left out by default, unless every model's is
36
+ MIN_SCORES = (0, 50, 70, 80, 85, 90, 95) # the choices offered (0: every score)
34
37
  POINTS_PER_DOUBLING = 15
35
38
 
36
39
 
@@ -55,13 +58,14 @@ def memory_gb(infos):
55
58
  SORTS = ("accuracy", "score")
56
59
 
57
60
 
58
- def index(rows, models=None, memory=None, sort="accuracy"):
61
+ def index(rows, models=None, memory=None, sort="accuracy", min_score=MIN_SCORE):
59
62
  """{"questions": n shared, "models": [{model, P, T, A, S, cut, cut_pct, tokens, total_s, passed, answers, alongside,
60
63
  fewest, fastest, tps, ttft, memory_gb, repeats: (fewest, most) answers per question}],
61
- "left_out": [(model, questions answered)], "no_answers": [model], "most": questions answered by the
62
- most-answered model}, best accuracy (sort="accuracy") or score (sort="score") first. S: the mean
64
+ "left_out": [(model, questions answered)], "low_score": [(model, score %) left out below MIN_SCORE],
65
+ "no_answers": [model], "most": questions answered by the most-answered model}, best accuracy (sort="accuracy") or score (sort="score") first. S: the mean
63
66
  score (partial credit) in %. models: the ones to compare (default: every model in rows
64
- with enough answers; models named here are compared whatever their count). memory: memory_gb()."""
67
+ with enough answers and a score of at least min_score, 0 for any; models named here are compared
68
+ whatever their count and score). memory: memory_gb()."""
65
69
  no_answers = sorted({r["model"] for r in rows} - {r["model"] for r in rows if not r.get("error")})
66
70
  rows = [r for r in rows if not r.get("error")] # a server or connection error isn't an answer
67
71
  tests = compare.per_question(rows)
@@ -70,47 +74,70 @@ score (partial credit) in %. models: the ones to compare (default: every model i
70
74
  for m in t["cells"]:
71
75
  counts[m] = counts.get(m, 0) + 1
72
76
  most = max(counts.values(), default=0)
77
+ picked = models
73
78
  if models:
74
79
  no_answers = [m for m in no_answers if m in models]
75
80
  models, left_out = sorted(m for m in models if m in counts), []
76
81
  else:
77
82
  models = sorted(m for m in counts if counts[m] >= MIN_SHARE * most)
78
83
  left_out = sorted(((m, n) for m, n in counts.items() if m not in models), key=lambda x: (-x[1], x[0]))
79
- shared = [t for t in tests if models and all(m in t["cells"] for m in models)]
80
- out = []
81
- for m in models:
82
- cells = [t["cells"][m] for t in shared]
83
- answers = sum(c["n"] for c in cells)
84
- out.append({"model": m, "tokens": sum(c["tokens"] or 0 for c in cells),
85
- "total_s": sum(c["secs"] or 0 for c in cells),
86
- "passed": sum(c["passed"] for c in cells), "answers": answers,
87
- "A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
88
- "S": 100 * sum(c["score"] * c["n"] for c in cells) / answers if answers else None,
89
- # failed only because max_tokens ran out (overthinking, not wrong yet)
90
- "cut": sum(c["truncated"] for c in cells),
91
- "cut_pct": 100 * sum(c["truncated"] for c in cells) / answers if answers else None,
92
- # ran past the max_tokens sent: the provider didn't stop it, so it had more room than asked
93
- "over": sum(c.get("over", 0) for c in cells),
94
- # generation speed and time to first token over the same answers (medians)
95
- "tps": compare.median(r.get("gen_tps") for c in cells for r in c["rows"]),
96
- "ttft": compare.median(r.get("ttft") for c in cells for r in c["rows"]),
97
- "memory_gb": (memory or {}).get(m),
98
- "repeats": (min(c["n"] for c in cells), max(c["n"] for c in cells)) if cells else None,
99
- # answers that ran while other models were served shared the machine's speed
100
- "alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
101
- fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
102
- fewest = min((x["tokens"] for x in out if x["tokens"] > 0), default=None)
103
- for x in out:
104
- x["P"], x["T"] = score(x["tokens"], fewest), score(x["total_s"], fastest)
105
- # shown on the bars: how many times the leanest / fastest model's total (1.0 = the best)
106
- x["tokens_x"] = x["tokens"] / fewest if fewest and x["tokens"] else None
107
- x["time_x"] = x["total_s"] / fastest if fastest and x["total_s"] else None
108
- x["fewest"] = bool(fewest) and x["tokens"] == fewest
109
- x["fastest"] = bool(fastest) and x["total_s"] == fastest
84
+
85
+ def compare_on(models):
86
+ shared = [t for t in tests if models and all(m in t["cells"] for m in models)]
87
+ out = []
88
+ for m in models:
89
+ cells = [t["cells"][m] for t in shared]
90
+ answers = sum(c["n"] for c in cells)
91
+ out.append({"model": m, "tokens": sum(c["tokens"] or 0 for c in cells),
92
+ "total_s": sum(c["secs"] or 0 for c in cells),
93
+ "passed": sum(c["passed"] for c in cells), "answers": answers,
94
+ "A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
95
+ "S": 100 * sum(c["score"] * c["n"] for c in cells) / answers if answers else None,
96
+ # failed only because max_tokens ran out (overthinking, not wrong yet)
97
+ "cut": sum(c["truncated"] for c in cells),
98
+ "cut_pct": 100 * sum(c["truncated"] for c in cells) / answers if answers else None,
99
+ # ran past the max_tokens sent: the provider didn't stop it, so it had more room than asked
100
+ "over": sum(c.get("over", 0) for c in cells),
101
+ # generation speed and time to first token over the same answers (medians)
102
+ "tps": compare.median(r.get("gen_tps") for c in cells for r in c["rows"]),
103
+ "ttft": compare.median(r.get("ttft") for c in cells for r in c["rows"]),
104
+ "memory_gb": (memory or {}).get(m),
105
+ "repeats": (min(c["n"] for c in cells), max(c["n"] for c in cells)) if cells else None,
106
+ # answers that ran while other models were served shared the machine's speed
107
+ "alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
108
+ fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
109
+ fewest = min((x["tokens"] for x in out if x["tokens"] > 0), default=None)
110
+ for x in out:
111
+ x["P"], x["T"] = score(x["tokens"], fewest), score(x["total_s"], fastest)
112
+ # shown on the bars: how many times the leanest / fastest model's total (1.0 = the best)
113
+ x["tokens_x"] = x["tokens"] / fewest if fewest and x["tokens"] else None
114
+ x["time_x"] = x["total_s"] / fastest if fastest and x["total_s"] else None
115
+ x["fewest"] = bool(fewest) and x["tokens"] == fewest
116
+ x["fastest"] = bool(fastest) and x["total_s"] == fastest
117
+ return shared, out
118
+
119
+ shared, out = compare_on(models)
120
+ low_score = []
121
+ if not picked and min_score:
122
+ # by default, models scoring below min_score are left out too (unless every model is), and the rest
123
+ # compared again: without them the shared questions can grow, and scores move, so until none is below
124
+ while True:
125
+ low = [x for x in out if _score(x) < min_score]
126
+ if not low or len(low) == len(out):
127
+ break
128
+ low_score += [(x["model"], _score(x)) for x in low]
129
+ models = [m for m in models if m not in {x["model"] for x in low}]
130
+ shared, out = compare_on(models)
131
+ low_score.sort(key=lambda x: (-x[1], x[0]))
110
132
  first, then = ("S", "A") if sort == "score" else ("A", "S")
111
133
  out.sort(key=lambda x: (-(x[first] or 0), -(x[then] or 0), -(x["T"] or 0), x["model"]))
112
- return {"questions": len(shared), "models": out, "left_out": left_out, "no_answers": no_answers,
113
- "most": most}
134
+ return {"questions": len(shared), "models": out, "left_out": left_out, "low_score": low_score,
135
+ "min_score": min_score, "no_answers": no_answers, "most": most}
136
+
137
+
138
+ def _score(x):
139
+ """A model's score (partial credit, %), or its accuracy when there's none."""
140
+ return x["S"] if x.get("S") is not None else (x["A"] or 0)
114
141
 
115
142
 
116
143
  # (letter, name, which way is better, what the bar measures, the number beside it, colour). P and T bars
@@ -200,13 +227,17 @@ def bars(result, width=20, markup=True, click=None):
200
227
 
201
228
 
202
229
  def left_out_lines(result):
203
- """Plain sentences on the models not compared (too few answers, or only server errors) and on
230
+ """Plain sentences on the models not compared (too few answers, a low score, or only server errors) and on
204
231
  answers that shared the machine with other models."""
205
232
  out = []
206
233
  if result["left_out"]:
207
234
  out.append("Left out (fewer than half as many answers as the most-answered model; pick them under "
208
235
  "Models or --only to include them): "
209
236
  + ", ".join(f"{m} ({n} of {result['most']})" for m, n in result["left_out"]))
237
+ if result.get("low_score"):
238
+ out.append(f"Left out (scored below {result.get('min_score', MIN_SCORE):g}%; lower the minimum score, or pick "
239
+ "them under Models or --only, to include them): "
240
+ + ", ".join(f"{m} ({compare.pct(v / 100)})" for m, v in result["low_score"]))
210
241
  if result["no_answers"]:
211
242
  out.append("No answers, only server errors: " + ", ".join(result["no_answers"]))
212
243
  shared = [x["model"] for x in result["models"] if x["alongside"]]
@@ -271,14 +302,14 @@ def scope_note(result, models):
271
302
  if any(x.get("over") for x in result["models"]) else ""))
272
303
 
273
304
 
274
- def for_engine(e, labels=None, packs=None, results_dir=None, sort="accuracy"):
305
+ def for_engine(e, labels=None, packs=None, results_dir=None, sort="accuracy", min_score=MIN_SCORE):
275
306
  """(result, models) from a workspace's finished results (results on their way out excluded)."""
276
307
  paths = [p for p in compare.default_paths(results_dir or e.results_dir) if not e.superseded(p)]
277
308
  rows, infos = compare.load(paths) if paths else ([], [])
278
309
  if packs:
279
310
  rows = [r for r in rows if r["suite"] in packs]
280
311
  models = sorted({r["model"] for r in rows} & set(labels) if labels else {r["model"] for r in rows})
281
- return index(rows, models if labels else None, memory_gb(infos), sort), models
312
+ return index(rows, models if labels else None, memory_gb(infos), sort, min_score), models
282
313
 
283
314
 
284
315
  def markdown(result, models):
@@ -405,6 +405,9 @@ def cmd_pta(argv):
405
405
  p.add_argument("--width", type=int, default=20, help="bar width in characters (default 20)")
406
406
  p.add_argument("--sort", choices=("accuracy", "score"), default="accuracy",
407
407
  help="order models by answers right (default) or by score (partial credit)")
408
+ p.add_argument("--min-score", type=float, default=None,
409
+ help="leave out models scoring below this %% (default 85; 0 shows every model; "
410
+ "models named with --only are always shown)")
408
411
  p.add_argument("--models")
409
412
  p.add_argument("--results-dir")
410
413
  a = p.parse_args(argv)
@@ -413,7 +416,8 @@ def cmd_pta(argv):
413
416
  e = engine.Engine(models_path=a.models, results_dir=a.results_dir)
414
417
  labels = [x.strip() for x in a.only.split(",")] if a.only else None
415
418
  packs = [x.strip() for x in a.packs.split(",")] if a.packs else None
416
- result, models = pta.for_engine(e, labels, packs, sort=a.sort)
419
+ result, models = pta.for_engine(e, labels, packs, sort=a.sort,
420
+ min_score=pta.MIN_SCORE if a.min_score is None else a.min_score)
417
421
  out = Console(highlight=False)
418
422
  if not models:
419
423
  sys.exit("no finished results to compare" + (" for those models or packs" if labels or packs else ""))
@@ -2934,6 +2934,7 @@ class ResultsScreen(Screen):
2934
2934
  self.smoke_dir = smoke_dir
2935
2935
 
2936
2936
  def compose(self) -> ComposeResult:
2937
+ from . import pta
2937
2938
  yield Header()
2938
2939
  with Horizontal(id="results-top"):
2939
2940
  yield Static(id="results-info")
@@ -2945,6 +2946,9 @@ class ResultsScreen(Screen):
2945
2946
  yield Select([("All packs", "")], id="pta-pack", allow_blank=False, value="")
2946
2947
  yield Select([("By accuracy", "accuracy"), ("By score", "score")], id="pta-sort",
2947
2948
  allow_blank=False, value="accuracy")
2949
+ yield Select([("Any score", 0)] + [(f"Score ≥ {n}%", n) for n in pta.MIN_SCORES if n],
2950
+ id="pta-min-score", allow_blank=False,
2951
+ value=getattr(self.app, "pta_min_score", pta.MIN_SCORE))
2948
2952
  yield Button("Models: all", id="pta-models")
2949
2953
  yield Static("[dim]P parsimony (tokens): tokens used, reasoning and answer. T time: total time. "
2950
2954
  "A accuracy: answers right. Verdicts and critical failures are in Production "
@@ -3275,6 +3279,13 @@ class ResultsScreen(Screen):
3275
3279
  out = insights.panels(rows, models, e.packs, ref, bases, by, engine=e if main else None)
3276
3280
  body.update("\n\n".join(insights.render(p, i) for i, p in enumerate(out, 1)))
3277
3281
 
3282
+ @on(Select.Changed, "#pta-min-score")
3283
+ def pta_min_score_changed(self, event):
3284
+ """The minimum score: models below it are left out of the PTA index unless picked (kept for the session)."""
3285
+ self.app.pta_min_score = event.value
3286
+ if getattr(self, "rows", None) is not None:
3287
+ self.fill_pta()
3288
+
3278
3289
  @on(Select.Changed, "#pta-sort")
3279
3290
  def pta_sort_changed(self):
3280
3291
  """The Sort dropdown orders the bars and the table, taking over from a column sorted by a click."""
@@ -3351,9 +3362,10 @@ class ResultsScreen(Screen):
3351
3362
  self.query_one("#pta-bars", Static).update("")
3352
3363
  self.query_one("#pta-left-out", Static).update("")
3353
3364
  return
3354
- # none picked: every model with enough answers (pta.index leaves out the rest, and says so)
3365
+ # none picked: every model with enough answers and score (pta.index leaves out the rest, and says so)
3355
3366
  result = pta.index(rows, sorted(chosen) or None, pta.memory_gb(self.infos),
3356
- self.query_one("#pta-sort", Select).value or "accuracy")
3367
+ self.query_one("#pta-sort", Select).value or "accuracy",
3368
+ self.query_one("#pta-min-score", Select).value or 0)
3357
3369
  self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
3358
3370
  self.pta_result = result
3359
3371
  removed = [m for m in self.pta_removed_now() if m in present]
@@ -3487,10 +3499,10 @@ class ResultsScreen(Screen):
3487
3499
  self.pq_summary_args = (tests, models) # to refit on resize
3488
3500
  rows = compare.compare_models(shared, models)
3489
3501
  # short headings so every column fits; the line above the table says what each one is
3490
- header = ("Model", "Time", "Passed", "Med tok", "Tokens", "Tok/✓", "Med s", "Prompt t/s", "Gen t/s",
3491
- "Fewest", "Fastest", "Cut off")
3502
+ header = ("Model", "Time", "Passed", "Pass rate", "Med tok", "Tokens", "Tok/✓", "Med s", "Prompt t/s",
3503
+ "Gen t/s", "Fewest", "Fastest", "Cut off")
3492
3504
  best = {k: max((r[k] for r in rows if r[k] is not None), default=None)
3493
- for k in ("passed", "fewest", "fastest", "avg_pp_tps", "avg_gen_tps")}
3505
+ for k in ("passed", "pass_rate", "fewest", "fastest", "avg_pp_tps", "avg_gen_tps")}
3494
3506
  low = {k: min(r[k] for r in rows if r[k] is not None) if any(r[k] is not None for r in rows) else None
3495
3507
  for k in ("med_tokens", "total_tokens", "tokens_per_pass", "med_secs", "total_secs")}
3496
3508
 
@@ -3505,6 +3517,7 @@ class ResultsScreen(Screen):
3505
3517
  hi(tt, ("-" if not tt else f"{tt:.1f}s" if tt < 60 else fmt_secs(tt)), "total_secs", "min"),
3506
3518
  hi(r["passed"], f"{r['passed']}/{r['n']}", "passed", "max")
3507
3519
  + (f" [dim]{r['score']:.0%}[/dim]" if r["passed"] < r["n"] and r["score"] else ""),
3520
+ hi(r["pass_rate"], f"{r['pass_rate']:.0%}", "pass_rate", "max"),
3508
3521
  hi(r["med_tokens"], f"{r['med_tokens']:,.0f}" if r["med_tokens"] else "-", "med_tokens", "min"),
3509
3522
  hi(r["total_tokens"], f"{r['total_tokens']:,.0f}", "total_tokens", "min"),
3510
3523
  hi(r["tokens_per_pass"], f"{r['tokens_per_pass']:,.0f}" if r["tokens_per_pass"] else "-",
@@ -3517,6 +3530,9 @@ class ResultsScreen(Screen):
3517
3530
  hi(r["fastest"], str(r["fastest"]), "fastest", "max"),
3518
3531
  (f"[red]{r['cut']}[/red]" if r["cut"] else "0")
3519
3532
  + (f" [yellow]+{r['over']} over[/yellow]" if r.get("over") else "")])
3533
+ if all(t["cells"][m]["n"] == 1 for t in shared for m in models): # one answer each: Passed says it all
3534
+ i = header.index("Pass rate")
3535
+ header, cells = header[:i] + header[i + 1:], [c[:i] + c[i + 1:] for c in cells]
3520
3536
  table.add_columns(*header)
3521
3537
  tag_w = max(Text.from_markup(tag).cell_len + 1 for tag in tags.values())
3522
3538
  cells = fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3 - tag_w,
@@ -3537,7 +3553,7 @@ class ResultsScreen(Screen):
3537
3553
  "shared the machine's speed: compare their times with care.[/yellow]")
3538
3554
  title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
3539
3555
  "per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
3540
- "they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Fewest / Fastest: questions won; ties count for both. "
3556
+ "they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Pass rate (with repeats): answers right, repeats included (each question weighs the same). Fewest / Fastest: questions won; ties count for both. "
3541
3557
  "Cut off: answers that hit max_tokens. ×n beside a name: answers per question.[/dim]")
3542
3558
 
3543
3559
  @staticmethod
@@ -3696,6 +3712,7 @@ class EvalsApp(App):
3696
3712
  #ins-body { height: 1fr; }
3697
3713
  #pta-controls Select { width: 28; margin-right: 1; }
3698
3714
  #pta-controls #pta-sort { width: 18; }
3715
+ #pta-controls #pta-min-score { width: 18; }
3699
3716
  #pta-bars { height: auto; padding: 1 0 0 0; }
3700
3717
  #pta { width: 1fr; height: auto; margin-top: 1; }
3701
3718
  #pta-left-out { height: auto; padding: 1 0 0 0; }
@@ -1691,7 +1691,7 @@ class PTAIndex(unittest.TestCase):
1691
1691
  rows = [row("fast", "q1", True, 2), row("fast", "q2", False, 2),
1692
1692
  row("slow", "q1", True, 8), row("slow", "q2", True, 4), row("slow", "q2", True, 6, repeat=1),
1693
1693
  row("slow", "q3", True, 100)] # only slow answered q3: not compared
1694
- res = pta.index(rows)
1694
+ res = pta.index(rows, min_score=0) # fast scores 50%: kept
1695
1695
  self.assertEqual(res["questions"], 2)
1696
1696
  by = {x["model"]: x for x in res["models"]}
1697
1697
  self.assertEqual(by["fast"]["total_s"], 4)
@@ -1777,6 +1777,30 @@ class PTAIndex(unittest.TestCase):
1777
1777
  picked = pta.index(rows, ["a", "few"]) # picked by hand: compared anyway
1778
1778
  self.assertEqual((picked["questions"], picked["left_out"]), (1, []))
1779
1779
 
1780
+ def test_low_scores_left_out_by_default(self):
1781
+ from tuieval import pta
1782
+
1783
+ def row(model, test, ok):
1784
+ return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "score": 1.0 if ok else 0.0,
1785
+ "latency": 1000, "tokens": 10, "truncated": False, "gen_tps": None, "repeat": 0}
1786
+ rows = ([row("good", f"q{i}", True) for i in range(10)]
1787
+ + [row("ok", f"q{i}", i != 0) for i in range(10)] # 90%
1788
+ + [row("weak", f"q{i}", i < 5) for i in range(8)]) # 62.5%, skipped q8 and q9
1789
+ res = pta.index(rows)
1790
+ self.assertEqual([x["model"] for x in res["models"]], ["good", "ok"])
1791
+ self.assertEqual(res["questions"], 10) # compared again without it: q8 and q9 count now
1792
+ self.assertEqual(res["low_score"], [("weak", 62.5)])
1793
+ self.assertIn("scored below 85%", "\n".join(pta.left_out_lines(res)))
1794
+ self.assertIn("weak (62.5%)", "\n".join(pta.left_out_lines(res)))
1795
+ picked = pta.index(rows, ["good", "weak"]) # picked by hand: shown whatever the score
1796
+ self.assertEqual(([x["model"] for x in picked["models"]], picked["low_score"]), (["good", "weak"], []))
1797
+ low = [r for r in rows if r["model"] != "good"]
1798
+ self.assertEqual(pta.index([r for r in low if r["model"] == "weak"])["low_score"], []) # all low: kept
1799
+ self.assertEqual([x["model"] for x in pta.index(low)["models"]], ["ok"])
1800
+ self.assertEqual([x["model"] for x in pta.index(rows, min_score=0)["models"]], ["good", "ok", "weak"])
1801
+ self.assertEqual([x["model"] for x in pta.index(rows, min_score=95)["models"]], ["good"])
1802
+ self.assertIn("scored below 95%", "\n".join(pta.left_out_lines(pta.index(rows, min_score=95))))
1803
+
1780
1804
  def test_results_sort_values(self):
1781
1805
  from tuieval.tui import diff_badge, sort_cell, sort_key, sort_value
1782
1806
  got = [sort_value(c) for c in ("1,234 ★", "3.1k", "2.4×", "4m10s", "1h02m", "12.5s", diff_badge("hard"),
@@ -1964,7 +1988,8 @@ class PTAIndex(unittest.TestCase):
1964
1988
  finally:
1965
1989
  right.stop()
1966
1990
  wrong.stop()
1967
- out = tuieval(ws, "pta").stdout
1991
+ self.assertIn("scored below 85%", tuieval(ws, "pta").stdout) # hosted (0%) left out by default
1992
+ out = tuieval(ws, "pta", "--min-score", "0").stdout
1968
1993
  self.assertIn("Compared on the 4 questions all 2 model(s) answered", out)
1969
1994
  bars = {l.split()[0]: l for l in out.splitlines() if "█" in l}
1970
1995
  self.assertEqual((bars["good"].count("1.0×"), "100%" in bars["good"]), (2, True))
@@ -1983,6 +2008,7 @@ class PTAIndex(unittest.TestCase):
1983
2008
  app = EvalsApp({})
1984
2009
  async with app.run_test(size=(180, 60)) as pilot:
1985
2010
  await pilot.pause()
2011
+ app.pta_min_score = 0 # hosted scores 0%
1986
2012
  app.push_screen(ResultsScreen())
1987
2013
  await pilot.pause(0.5)
1988
2014
  t = app.screen.query_one("#pta", DataTable)
@@ -2451,6 +2477,52 @@ class FirstRun(unittest.TestCase):
2451
2477
  "['once [yellow]×1[/]', 'thrice [dim]×3[/]']", # Comparison: only the questions both answered
2452
2478
  ], out.stderr[-2000:])
2453
2479
 
2480
+ def test_pass_rate_counts_every_answer(self):
2481
+ from tuieval import compare
2482
+ row = {"suite": "p", "latency": 1000, "tokens": 10, "truncated": False, "gen_tps": 20.0, "prompt_tps": None}
2483
+ oks = {"a": {"q0": [True, True], "q1": [True, False], "q2": [False, False, False, True]}, # ×2, ×2, ×4
2484
+ "b": {"q0": [True], "q1": [False], "q2": [False]},
2485
+ "c": {"q0": [False], "q1": [False], "q2": [False]}}
2486
+ rows = [dict(row, model=m, test=f"p: {q}", ok=ok, repeat=r)
2487
+ for m, qs in oks.items() for q, v in qs.items() for r, ok in enumerate(v)]
2488
+ out = {r["model"]: r for r in compare.compare_models(compare.per_question(rows), list(oks))}
2489
+ self.assertEqual(out["a"]["passed"], 1) # strict: right on every repeat
2490
+ self.assertAlmostEqual(out["a"]["pass_rate"], (1 + 0.5 + 0.25) / 3) # each question weighs the same
2491
+ self.assertAlmostEqual(out["b"]["pass_rate"], 1 / 3) # ×1: the same as passed / questions
2492
+ self.assertEqual(out["c"]["pass_rate"], 0)
2493
+
2494
+ def test_comparison_shows_pass_rate(self):
2495
+ tuieval(self.ws, "add", "my-model", "--server", "local")
2496
+ code = textwrap.dedent("""
2497
+ import asyncio
2498
+ from rich.text import Text
2499
+ from tuieval import compare
2500
+ from tuieval.tui import EvalsApp, ResultsScreen
2501
+ from textual.widgets import DataTable, TabbedContent
2502
+ oks = {"steady": [[True, True], [True, True]], "shaky": [[True, True], [True, False]]}
2503
+ rows = [{"model": m, "suite": "p", "test": f"p: q{q}", "ok": ok, "latency": 1000, "tokens": 10,
2504
+ "truncated": False, "gen_tps": 20.0, "prompt_tps": None, "repeat": r}
2505
+ for m, qs in oks.items() for q, v in enumerate(qs) for r, ok in enumerate(v)]
2506
+ async def go():
2507
+ app = EvalsApp({})
2508
+ async with app.run_test(size=(160, 40)) as pilot:
2509
+ await pilot.pause()
2510
+ app.push_screen(ResultsScreen())
2511
+ await pilot.pause(0.5)
2512
+ app.screen.query_one(TabbedContent).active = "tab-per-question"
2513
+ await pilot.pause(0.3)
2514
+ app.screen.fill_pq_summary(compare.per_question(rows), list(oks))
2515
+ await pilot.pause(0.3)
2516
+ t = app.screen.query_one("#pq-summary", DataTable)
2517
+ i = [str(c.label) for c in t.ordered_columns].index("Pass rate")
2518
+ print(str(t.ordered_columns[i - 1].label), [t.get_row_at(n)[i] for n in range(t.row_count)])
2519
+ asyncio.run(go())
2520
+ """)
2521
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
2522
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
2523
+ # right after Passed; the best in bold green
2524
+ self.assertEqual(out.stdout.strip(), "Passed ['[bold green]100%[/bold green]', '75%']", out.stderr[-2000:])
2525
+
2454
2526
  def test_results_open_on_pta_index(self):
2455
2527
  tuieval(self.ws, "add", "my-model", "--server", "local")
2456
2528
  code = textwrap.dedent("""
File without changes
File without changes