tuieval 0.2.0.dev42__tar.gz → 0.2.0.dev50__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.dev42 → tuieval-0.2.0.dev50}/CHANGELOG.md +2 -1
  2. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/models.md +5 -6
  4. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/cli.py +1 -1
  6. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/client.py +11 -1
  7. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/engine.py +3 -0
  8. tuieval-0.2.0.dev50/src/tuieval/pta.py +176 -0
  9. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/run_evals.py +9 -4
  10. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/tui.py +32 -33
  11. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/tests/mock_server.py +4 -1
  12. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/tests/test_tuieval.py +96 -31
  13. tuieval-0.2.0.dev42/src/tuieval/pta.py +0 -185
  14. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/.gitignore +0 -0
  15. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/LICENSE +0 -0
  16. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/README.md +0 -0
  17. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/RELEASING.md +0 -0
  18. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/README.md +0 -0
  19. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/favicon.ico +0 -0
  20. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/readme-header.png +0 -0
  21. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/social-preview.png +0 -0
  22. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-1024.png +0 -0
  23. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-128.png +0 -0
  24. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-16.png +0 -0
  25. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-16.svg +0 -0
  26. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-256.png +0 -0
  27. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-32.png +0 -0
  28. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-48.png +0 -0
  29. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-512.png +0 -0
  30. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-64.png +0 -0
  31. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  32. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/brand/tuieval-icon.svg +0 -0
  33. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/tui-answer-reasoning.png +0 -0
  34. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/tui-run-reasoning.png +0 -0
  35. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/images/tui-setup.png +0 -0
  36. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/docs/writing-packs.md +0 -0
  37. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/pyproject.toml +0 -0
  38. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/__init__.py +0 -0
  39. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/__main__.py +0 -0
  40. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/builtin.toml +0 -0
  41. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/compare.py +0 -0
  42. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/doctor.py +0 -0
  43. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/export.py +0 -0
  44. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/graders/__init__.py +0 -0
  45. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/graders/answer.py +0 -0
  46. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/graders/code.py +0 -0
  47. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/graders/rag.py +0 -0
  48. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/graders/reply.py +0 -0
  49. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/graders/tool_call.py +0 -0
  50. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/machines.py +0 -0
  51. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/packs.py +0 -0
  53. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/profiles.py +0 -0
  54. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev42 → tuieval-0.2.0.dev50}/src/tuieval/yamlout.py +0 -0
@@ -2,7 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- - PTA index: privacy, time (total, relative to the fastest) and accuracy per model, as a triangle in Results, `tuieval pta` and the report.
5
+ - A model a server won't serve (HTTP 401-404, e.g. OpenRouter's "No endpoints found") is never judged on it: those answers don't count, and the model stops at once instead of failing every question.
6
+ - PTA index: parsimony (tokens used) and speed (time), each 100 for the best and 15 points less per doubling, and accuracy per model, as bars: the first Results tab (where `r` lands), `tuieval pta` and the report. Server errors aren't answers, and models with few answers are left out unless picked.
6
7
  - Run several models at a time: `parallel_models` per machine, **Models at a time** in the TUI, or `tuieval run --parallel N` (default 1). Models wait for memory to fit, and answers note what ran alongside them.
7
8
  - More evals during a run: `n` opens Setup, and Start adds them to the run going (same tier) or queues them after it.
8
9
  - A new workspace's `models.toml` is 23 lines: the llama, local and OpenRouter servers and the defaults are built in (docs/models.md lists them in full; a server defined in `models.toml` replaces the built-in one with that name). Existing workspaces keep their settings; built-in servers they don't define become available, and adding a model still prefers the workspace's own servers.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev42
3
+ Version: 0.2.0.dev50
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
@@ -86,13 +86,13 @@ By default a run serves one model at a time. On a machine with room for more, se
86
86
 
87
87
  ## PTA index
88
88
 
89
- The **PTA index** puts privacy, time and accuracy side by side, 0-100 each, as a radar triangle (Results → PTA index, `tuieval pta`, and a section in `tuieval report`). Each model is a triangle reaching each corner as far as its score: bigger means better all round. There's no combined number; verdicts and critical failures stay in Production readiness.
89
+ The **PTA index** puts parsimony (tokens), speed (time) and accuracy side by side, 0-100 each and higher is better, as a bar per score for each model, with the numbers and a table underneath. It's the first tab in Results, where `r` opens, and it's also `tuieval pta` and a section in `tuieval report`. There's no combined number; verdicts and critical failures stay in Production readiness.
90
90
 
91
- - **P privacy**: 100 when prompts stay on machines you control (tuieval starts the server, its `url` is this machine, or the server has `private = true`, e.g. your own box on the network); 0 for a hosted API.
92
- - **T time**: the total time to answer every question compared (each question at its median over repeats), relative to the fastest model: `100 × fastest total ÷ this model's total`. Adding a faster model lowers the others.
93
- - **A accuracy**: the share of answers to those questions that passed.
91
+ - **P parsimony** (a score out of 100, not a percentage): from the tokens used, reasoning and answer, on every question compared (each question at its median over repeats). The fewest tokens score 100 (★ in the **tokens** column), and every doubling costs 15 points: `100 − 15 × log2(this model's tokens ÷ fewest)`, never below 0. Unlike speed it doesn't depend on your machine: it's how much a model says to get to an answer, which is what you pay for on an API and what fills its context.
92
+ - **T speed** (a score out of 100, not a percentage): the same from the total time to answer those questions. The fastest model scores 100 (★ on its total time), and every doubling of the total time costs 15 points: `100 − 15 × log2(this model's total ÷ fastest total)`, never below 0 (about 100× slower). Twice as slow is 85, 10× slower is 50. Adding a faster model lowers the others. The table's **total time** column has the times themselves (lower is better).
93
+ - **A accuracy** (a percentage): the share of answers to those questions that passed.
94
94
 
95
- Models are compared on the questions all of them answered, so one that skipped some never looks faster. Pick the models and pack to compare in the TUI, or use `--only` and `--packs`.
95
+ 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.
96
96
 
97
97
  ## Tuning
98
98
 
@@ -140,7 +140,6 @@ On Apple Silicon Macs, once the system's GPU allocations pass about half of RAM,
140
140
  |---|---|
141
141
  | `cmd` | command that starts the server (no `cmd` = an already-running server at `url`). Placeholders: `{model}`, `{served_name}`, `{port}`, `{mmproj}`, `{ctx}`, `{kv_type}`, `{root}` (the workspace), and the tune placeholders. An argument `env:NAME=value` sets an environment variable instead. |
142
142
  | `url` | an already-running server's base URL |
143
- | `private` | `true` for a server on a machine you control (e.g. your own box on the network): its models score 100 for privacy in the PTA index |
144
143
  | `port` | the port `cmd` serves on |
145
144
  | `cwd` | folder to start `cmd` in |
146
145
  | `model_is_path` | `model` is a file or folder: check it exists before starting |
@@ -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.dev42'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev42')
21
+ __version__ = version = '0.2.0.dev50'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev50')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -14,7 +14,7 @@ Start here
14
14
 
15
15
  Day to day
16
16
  tuieval compare [...] scorecards (--speed, --pairwise, --failures)
17
- tuieval pta the PTA index: privacy, time and accuracy per model, as a triangle
17
+ tuieval pta the PTA index: parsimony, speed 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
20
20
  tuieval items tests that don't separate models or look broken
@@ -17,6 +17,10 @@ import time
17
17
  import urllib.parse
18
18
 
19
19
  SERVER_ERROR_PREFIXES = ("server returned HTTP 5", "server returned HTTP 429", "server error", "connection error")
20
+ # the model can't be reached there at all (a bad key, no credits, or a hosted API with no provider serving it, e.g.
21
+ # OpenRouter's 404 "No endpoints found"): not the model's answer either, and waiting won't fix it
22
+ UNAVAILABLE_PREFIXES = ("server returned HTTP 401", "server returned HTTP 402", "server returned HTTP 403",
23
+ "server returned HTTP 404")
20
24
 
21
25
 
22
26
  def is_server_error(error):
@@ -27,9 +31,15 @@ def is_server_error(error):
27
31
  and "timeout" not in error.lower()
28
32
 
29
33
 
34
+ def is_unavailable(error):
35
+ """True for an error saying the model isn't available on that server at all: HTTP 401, 402, 403 or
36
+ 404. Never the model's answer, and not worth retrying."""
37
+ return bool(error) and error.startswith(UNAVAILABLE_PREFIXES)
38
+
39
+
30
40
  def server_error_row(rec):
31
41
  """A stored answer that was really a server failure (never a judgement of the model)."""
32
- return not rec.get("pass") and is_server_error(rec.get("reason"))
42
+ return not rec.get("pass") and (is_server_error(rec.get("reason")) or is_unavailable(rec.get("reason")))
33
43
 
34
44
 
35
45
  class Cancelled(Exception):
@@ -2105,6 +2105,9 @@ class Engine:
2105
2105
  self._check()
2106
2106
  if res["error"] and not alive():
2107
2107
  raise ModelFailed(f"the server stopped: {res['error']}")
2108
+ if client.is_unavailable(res["error"]): # never an answer; retrying won't help
2109
+ raise ModelFailed(f"the model isn't available there (not counted against it): "
2110
+ f"{res['error'][:300]}")
2108
2111
  if not client.is_server_error(res["error"]):
2109
2112
  break
2110
2113
  # the server failed, not the model: never recorded as an answer
@@ -0,0 +1,176 @@
1
+ """PTA index: parsimony, speed (time) and accuracy per model, 0-100 each and higher is better, shown
2
+ as a bar per score for each model.
3
+
4
+ P parsimony a score out of 100, from the tokens (reasoning and answer) used on every question
5
+ compared (each question at its median over repeats): 100 for the model that used the
6
+ fewest, POINTS_PER_DOUBLING points less for every doubling (0 at about 100 times more).
7
+ Unlike T it doesn't depend on the machine: it's how much a model says to get there.
8
+ T speed a score out of 100, the same from the total time to answer those questions: 100 for
9
+ the fastest model. Log scales, so models that are all far behind the best still differ.
10
+ A accuracy a percentage: the share of answers to those questions that passed.
11
+
12
+ Models are compared on the questions all of them answered, so one that skipped some never looks
13
+ faster. Server and connection errors aren't answers. By default a model with fewer than half as many
14
+ answers as the most-answered model is left out (listed, so it can be picked), or it would shrink the
15
+ shared questions for everyone. There's no combined number: the three stay side by side, and verdicts
16
+ and critical failures stay where they are (verdict.py).
17
+ """
18
+ import math
19
+
20
+ from rich.markup import escape as rich_escape
21
+
22
+ from . import compare
23
+
24
+ MIN_SHARE = 0.5 # fewer answers than this share of the most-answered model's: left out by default
25
+ POINTS_PER_DOUBLING = 15
26
+
27
+
28
+ def score(total, best):
29
+ """P and T: 100 for the best (lowest) total, POINTS_PER_DOUBLING less for every doubling, never below 0."""
30
+ if not total or not best:
31
+ return None
32
+ return max(0.0, 100 - POINTS_PER_DOUBLING * math.log2(total / best))
33
+
34
+
35
+ def index(rows, models=None):
36
+ """{"questions": n shared, "models": [{model, P, T, A, tokens, total_s, passed, answers, alongside,
37
+ fewest, fastest}],
38
+ "left_out": [(model, questions answered)], "no_answers": [model], "most": questions answered by the
39
+ most-answered model}, best accuracy first. models: the ones to compare (default: every model in rows
40
+ with enough answers; models named here are compared whatever their count)."""
41
+ no_answers = sorted({r["model"] for r in rows} - {r["model"] for r in rows if not r.get("error")})
42
+ rows = [r for r in rows if not r.get("error")] # a server or connection error isn't an answer
43
+ tests = compare.per_question(rows)
44
+ counts = {}
45
+ for t in tests:
46
+ for m in t["cells"]:
47
+ counts[m] = counts.get(m, 0) + 1
48
+ most = max(counts.values(), default=0)
49
+ if models:
50
+ no_answers = [m for m in no_answers if m in models]
51
+ models, left_out = sorted(m for m in models if m in counts), []
52
+ else:
53
+ models = sorted(m for m in counts if counts[m] >= MIN_SHARE * most)
54
+ left_out = sorted(((m, n) for m, n in counts.items() if m not in models), key=lambda x: (-x[1], x[0]))
55
+ shared = [t for t in tests if models and all(m in t["cells"] for m in models)]
56
+ out = []
57
+ for m in models:
58
+ cells = [t["cells"][m] for t in shared]
59
+ answers = sum(c["n"] for c in cells)
60
+ out.append({"model": m, "tokens": sum(c["tokens"] or 0 for c in cells),
61
+ "total_s": sum(c["secs"] or 0 for c in cells),
62
+ "passed": sum(c["passed"] for c in cells), "answers": answers,
63
+ "A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
64
+ # answers that ran while other models were served shared the machine's speed
65
+ "alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
66
+ fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
67
+ fewest = min((x["tokens"] for x in out if x["tokens"] > 0), default=None)
68
+ for x in out:
69
+ x["P"], x["T"] = score(x["tokens"], fewest), score(x["total_s"], fastest)
70
+ x["fewest"] = bool(fewest) and x["tokens"] == fewest
71
+ x["fastest"] = bool(fastest) and x["total_s"] == fastest
72
+ out.sort(key=lambda x: (-(x["A"] or 0), -(x["T"] or 0), x["model"]))
73
+ return {"questions": len(shared), "models": out, "left_out": left_out, "no_answers": no_answers,
74
+ "most": most}
75
+
76
+
77
+ LETTERS = (("P", "parsimony", "/100", "cyan"), ("T", "speed", "/100", "magenta"), ("A", "accuracy", "%", "green"))
78
+
79
+
80
+ def bars(result, width=20, markup=True):
81
+ """Lines with a row per model and a bar per score (P, T, A), longer = better, each with its number:
82
+ the at-a-glance view. markup=False gives plain text (the report)."""
83
+ if not result["models"]:
84
+ return []
85
+ name_w = max(len(x["model"]) for x in result["models"])
86
+ cell_w = width + 6 # the bar, a space and "100%" or " 100"
87
+ head = " " * (name_w + 2) + "".join(f"{f'{k} {name}':<{cell_w}}" for k, name, _, _ in LETTERS)
88
+ lines = [f"[b]{head.rstrip()}[/b]" if markup else head.rstrip()]
89
+ for x in result["models"]:
90
+ cells = []
91
+ for k, _, unit, color in LETTERS:
92
+ v = x[k]
93
+ full = 0 if v is None else round(width * max(0, min(100, v)) / 100)
94
+ bar, rest = "█" * full, "░" * (width - full)
95
+ num = "-" if v is None else f"{v:.0f}{'%' if unit == '%' else ''}"
96
+ bar = f"[{color}]{bar}[/][grey37]{rest}[/]" if markup else bar + rest
97
+ cells.append(f"{bar} {num:>4}")
98
+ name = f"{x['model']:<{name_w}}"
99
+ lines.append(f"{rich_escape(name) if markup else name} " + " ".join(cells))
100
+ return lines
101
+
102
+
103
+ def left_out_lines(result):
104
+ """Plain sentences on the models not compared (too few answers, or only server errors) and on
105
+ answers that shared the machine with other models."""
106
+ out = []
107
+ if result["left_out"]:
108
+ out.append("Left out (fewer than half as many answers as the most-answered model; pick them under "
109
+ "Models or --only to include them): "
110
+ + ", ".join(f"{m} ({n} of {result['most']})" for m, n in result["left_out"]))
111
+ if result["no_answers"]:
112
+ out.append("No answers, only server errors: " + ", ".join(result["no_answers"]))
113
+ shared = [x["model"] for x in result["models"] if x["alongside"]]
114
+ if shared:
115
+ out.append("Some answers ran alongside other models, which shared the machine's speed: " + ", ".join(shared))
116
+ return out
117
+
118
+
119
+ def _score(v, unit=""):
120
+ """A is a percentage (%), P and T scores out of 100 (/100: on a log scale, not percentages)."""
121
+ return "-" if v is None else f"{v:.0f}{unit}"
122
+
123
+
124
+ def table(result):
125
+ """(header, rows) for a plain table of the index."""
126
+ header = ["model", "P parsimony /100", "T speed /100", "A accuracy %", "tokens", "total time",
127
+ "answers right"]
128
+ rows = [[x["model"], _score(x["P"]), _score(x["T"]), _score(x["A"], "%"),
129
+ f"{round(x['tokens']):,}" + (" ★" if x.get("fewest") else ""),
130
+ fmt_total(x["total_s"]) + (" ★" if x.get("fastest") else ""),
131
+ f"{x['passed']}/{x['answers']}"] for x in result["models"]]
132
+ return header, rows
133
+
134
+
135
+ def fmt_total(secs):
136
+ if not secs:
137
+ return "-"
138
+ if secs < 60:
139
+ return f"{secs:.1f}s"
140
+ secs = round(secs)
141
+ h, rem = divmod(secs, 3600)
142
+ return f"{h}h{rem // 60:02d}m" if h else f"{rem // 60}m{rem % 60:02d}s" if rem >= 60 else f"{rem}s"
143
+
144
+
145
+ def scope_note(result, models):
146
+ """What the scores compare, in a sentence."""
147
+ if not result["questions"]:
148
+ return "These models have no question in common yet, so there's nothing to compare."
149
+ return (f"Compared on the {result['questions']} questions all {len(result['models'])} model(s) answered. "
150
+ f"A is a percentage. P parsimony and T speed are scores out of 100, not percentages: 100 = the fewest "
151
+ f"tokens / the fastest total time (★), {POINTS_PER_DOUBLING} points less for each doubling; a better "
152
+ "model added lowers the others.")
153
+
154
+
155
+ def for_engine(e, labels=None, packs=None, results_dir=None):
156
+ """(result, models) from a workspace's finished results (results on their way out excluded)."""
157
+ paths = [p for p in compare.default_paths(results_dir or e.results_dir) if not e.superseded(p)]
158
+ rows, _ = compare.load(paths) if paths else ([], [])
159
+ if packs:
160
+ rows = [r for r in rows if r["suite"] in packs]
161
+ models = sorted({r["model"] for r in rows} & set(labels) if labels else {r["model"] for r in rows})
162
+ return index(rows, models if labels else None), models
163
+
164
+
165
+ def markdown(result, models):
166
+ """The index as a markdown section (tuieval report)."""
167
+ lines = ["## PTA index", "", scope_note(result, models), ""]
168
+ if not result["questions"]:
169
+ return lines
170
+ lines += ["```"] + bars(result, markup=False) + ["```", ""]
171
+ header, rows = table(result)
172
+ lines += ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
173
+ lines += ["| " + " | ".join(r) + " |" for r in rows]
174
+ for line in left_out_lines(result):
175
+ lines += ["", line]
176
+ return lines
@@ -389,11 +389,11 @@ def report_markdown(e, labels=None):
389
389
 
390
390
  def cmd_pta(argv):
391
391
  p = argparse.ArgumentParser(prog="tuieval pta",
392
- description="The PTA index: privacy, time and accuracy per model (0-100 each), as a "
393
- "triangle and a table, over the questions all the models answered.")
392
+ description="The PTA index: parsimony (tokens), speed and accuracy per model (0-100, higher is better), as a "
393
+ "bar per score for each model and a table, over the questions all the models answered.")
394
394
  p.add_argument("--only", help="comma-separated model labels (default: every model with results)")
395
395
  p.add_argument("--packs", help="comma-separated packs (default: all)")
396
- p.add_argument("--width", type=int, default=48, help="triangle width in characters (default 48)")
396
+ p.add_argument("--width", type=int, default=20, help="bar width in characters (default 20)")
397
397
  p.add_argument("--models")
398
398
  p.add_argument("--results-dir")
399
399
  a = p.parse_args(argv)
@@ -408,15 +408,20 @@ def cmd_pta(argv):
408
408
  sys.exit("no finished results to compare" + (" for those models or packs" if labels or packs else ""))
409
409
  out.print(pta.scope_note(result, models))
410
410
  if not result["questions"]:
411
+ for line in pta.left_out_lines(result):
412
+ out.print(line, markup=False)
411
413
  return
412
414
  out.print()
413
- for line in pta.triangle(result, a.width) + [""] + pta.legend(result):
415
+ for line in pta.bars(result, a.width):
414
416
  out.print(line)
415
417
  header, rows = pta.table(result)
416
418
  widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(header)]
417
419
  out.print()
418
420
  for line in [header] + rows:
419
421
  out.print(" ".join(c.ljust(w) for c, w in zip(line, widths)).rstrip(), markup=False)
422
+ for line in pta.left_out_lines(result):
423
+ out.print()
424
+ out.print(line, markup=False)
420
425
 
421
426
 
422
427
  def cmd_report(argv):
@@ -10,10 +10,10 @@ Run screen: live reasoning and answer, per-test ✓/✗, TTFT and tokens/s, p
10
10
  k skips the current model, c cancels everything, f pauses auto-scroll.
11
11
  r shows results at any time (also mid-run); n starts a new run when it ends.
12
12
  Enter on a Recent results row shows that answer in full.
13
- Results: opens on Per question, which compares each model's tokens and seconds on the same
14
- question. ctrl+r reloads (it also reloads when a run's pack finishes). Enter on a
15
- Failures row or a Per question cell shows the answer: grading and checks, reasoning,
16
- answer, question.
13
+ Results: opens on the PTA index (parsimony, speed and accuracy per model, as bars). Per
14
+ question compares each model's tokens and seconds on the same question. ctrl+r
15
+ reloads (it also reloads when a run's pack finishes). Enter on a Failures row or a
16
+ Per question cell shows the answer: grading and checks, reasoning, answer, question.
17
17
  Answer detail: [ and ] step to the previous/next answer; esc/q close.
18
18
  Anywhere: esc/q go back (q quits only on Setup), ctrl+q quits, w lists this session's runs and
19
19
  tunes. Leaving a live run or tune keeps it going; the header shows its progress.
@@ -331,11 +331,11 @@ with the model's reasoning and answer as they stream, then recent results with t
331
331
  v with several models at a time: stream the next one (k skips the one streaming)""",
332
332
  "ResultsScreen": """[b]Results[/b]
333
333
 
334
- Opens on [b]Per question[/b]. [b]Production readiness[/b] gives one verdict per model and use case,
335
- and what's missing when it's INCONCLUSIVE. The other tabs are the evidence:
334
+ Opens on the [b]PTA index[/b]: parsimony (tokens), speed and accuracy per model, a bar each (0-100,
335
+ longer = better). [b]Production readiness[/b] gives one verdict per model and use case, and what's
336
+ missing when it's INCONCLUSIVE. The other tabs are the evidence:
336
337
  Scorecard accuracy, critical failures and truncation per model and pack
337
338
  Speed & tokens time and tokens per answer (★ = nothing beats it on both accuracy and time)
338
- PTA index privacy, total time and accuracy per model (0-100 each), as a triangle
339
339
  Per question every question, model by model
340
340
  Is the difference real? whether one model is really better than another, or it's noise
341
341
  Tests that separate the questions that tell models apart
@@ -2341,7 +2341,18 @@ class ResultsScreen(Screen):
2341
2341
  yield Static(id="results-info")
2342
2342
  if self.smoke_dir:
2343
2343
  yield Button("Show this Smoke run's results", id="toggle-smoke")
2344
- with TabbedContent(initial="tab-per-question"):
2344
+ with TabbedContent(initial="tab-pta"):
2345
+ with TabPane("PTA index", id="tab-pta"):
2346
+ with Horizontal(id="pta-controls"):
2347
+ yield Select([("All packs", "")], id="pta-pack", allow_blank=False, value="")
2348
+ yield Button("Models: all", id="pta-models")
2349
+ yield Static("[dim]P parsimony: tokens used (reasoning and answer). T speed: total time. A accuracy: "
2350
+ "answers right. Longer bars are better for all three. Verdicts and critical failures "
2351
+ "are in Production readiness.[/dim]")
2352
+ yield Static(id="pta-note")
2353
+ yield Static(id="pta-bars")
2354
+ yield DataTable(id="pta", zebra_stripes=True, cursor_type="none")
2355
+ yield Static(id="pta-left-out")
2345
2356
  with TabPane("Production readiness"):
2346
2357
  yield Static(id="readiness-short")
2347
2358
  yield Static(self.READINESS_HELP, id="readiness-help")
@@ -2366,19 +2377,6 @@ class ResultsScreen(Screen):
2366
2377
  "On another machine, times are measured there if the model ran there, otherwise "
2367
2378
  "projected from each answer's token counts and that machine's tuned speeds.[/dim]")
2368
2379
  yield DataTable(id="speed", zebra_stripes=True)
2369
- with TabPane("PTA index", id="tab-pta"):
2370
- with Horizontal(id="pta-controls"):
2371
- yield Select([("All packs", "")], id="pta-pack", allow_blank=False, value="")
2372
- yield Button("Models: all", id="pta-models")
2373
- yield Static("[dim]P privacy: 100 when prompts stay on machines you control (local, or a server "
2374
- "marked private = true), 0 for a hosted API. T time: total time for every question "
2375
- "compared, relative to the fastest model (100). A accuracy: answers right. Bigger "
2376
- "triangle = better all round; verdicts and critical failures are in Production "
2377
- "readiness.[/dim]")
2378
- yield Static(id="pta-note")
2379
- with Horizontal(id="pta-body"):
2380
- yield Static(id="pta-triangle")
2381
- yield DataTable(id="pta", zebra_stripes=True, cursor_type="none")
2382
2380
  with TabPane("Per question", id="tab-per-question"):
2383
2381
  with Horizontal(id="pq-controls"):
2384
2382
  yield Select([("All packs", "")], id="pq-pack", allow_blank=False, value="")
@@ -2586,21 +2584,22 @@ class ResultsScreen(Screen):
2586
2584
  present = {r["model"] for r in rows}
2587
2585
  chosen = {m for m in self.app.pq_models if m in present}
2588
2586
  self.query_one("#pta-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
2589
- models = sorted(chosen or present)
2590
2587
  t = self.query_one("#pta", DataTable)
2591
2588
  t.clear(columns=True)
2592
- if not models:
2589
+ if not present:
2593
2590
  self.query_one("#pta-note", Static).update("No finished results to compare yet.")
2594
- self.query_one("#pta-triangle", Static).update("")
2591
+ self.query_one("#pta-bars", Static).update("")
2592
+ self.query_one("#pta-left-out", Static).update("")
2595
2593
  return
2596
- result = pta.index(rows, lambda m: pta.privacy(e.cfg, m), models)
2597
- extra = (f" [yellow]{len(pta.COLORS)} models are drawn at most; pick fewer with Models.[/yellow]"
2598
- if len(models) > len(pta.COLORS) else "")
2599
- self.query_one("#pta-note", Static).update(pta.scope_note(result, models) + extra)
2600
- self.query_one("#pta-triangle", Static).update(
2601
- "\n".join(pta.triangle(result, 48) + [""] + pta.legend(result)) if result["questions"] else "")
2594
+ # none picked: every model with enough answers (pta.index leaves out the rest, and says so)
2595
+ result = pta.index(rows, sorted(chosen) or None)
2596
+ self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
2597
+ self.query_one("#pta-bars", Static).update("\n".join(pta.bars(result)) if result["questions"] else "")
2598
+ self.query_one("#pta-left-out", Static).update(
2599
+ "\n".join(f"[dim]{rich_escape(line)}[/dim]" for line in pta.left_out_lines(result)))
2602
2600
  header, table = pta.table(result)
2603
- t.add_columns(*header)
2601
+ for i, h in enumerate(header): # sized to the contents: added before the rows, a column keeps its header's width
2602
+ t.add_column(h, width=max(len(h), *(len(r[i]) for r in table)) if table else None)
2604
2603
  t.add_rows(table)
2605
2604
 
2606
2605
  def fill_per_question(self):
@@ -2908,9 +2907,9 @@ class EvalsApp(App):
2908
2907
  #pq-controls { height: 3; }
2909
2908
  #pta-controls { height: 3; }
2910
2909
  #pta-controls Select { width: 28; margin-right: 1; }
2911
- #pta-body { height: auto; }
2912
- #pta-triangle { width: auto; padding: 1 2 0 0; }
2910
+ #pta-bars { height: auto; padding: 1 0 0 0; }
2913
2911
  #pta { width: 1fr; height: auto; margin-top: 1; }
2912
+ #pta-left-out { height: auto; padding: 1 0 0 0; }
2914
2913
  #pq-controls Checkbox { width: auto; }
2915
2914
  #pq-controls Select { width: 28; margin-right: 1; }
2916
2915
  #pq-models { margin-right: 1; min-width: 20; }
@@ -6,6 +6,7 @@ oracle answers each question with its test's `reference` (or the expected tool
6
6
  correct grader passes it
7
7
  wrong answers with the test's first `wrong` entry (or "ANSWER: 0")
8
8
  fixed always "ANSWER: 42"
9
+ unavailable HTTP 404 "No endpoints found" for every chat request
9
10
 
10
11
  Streams like llama.cpp: reasoning_content then content deltas, usage and timings at the end.
11
12
  """
@@ -77,6 +78,8 @@ class Handler(BaseHTTPRequestHandler):
77
78
  if isinstance(content, list):
78
79
  content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
79
80
  test = self.answers.get(" ".join(str(content).split()))
81
+ if self.mode == "unavailable": # like OpenRouter with no provider serving the model
82
+ return self._json({"error": {"message": f"No endpoints found for {self.model}.", "code": 404}}, 404)
80
83
  text, calls = reply_for(test, self.mode)
81
84
  self.send_response(200)
82
85
  self.send_header("Content-Type", "text/event-stream")
@@ -105,7 +108,7 @@ def main(argv=None):
105
108
  p = argparse.ArgumentParser()
106
109
  p.add_argument("--port", type=int, default=18090)
107
110
  p.add_argument("--packs", required=True)
108
- p.add_argument("--mode", choices=("oracle", "wrong", "fixed"), default="oracle")
111
+ p.add_argument("--mode", choices=("oracle", "wrong", "fixed", "unavailable"), default="oracle")
109
112
  p.add_argument("--model", default="mock")
110
113
  p.add_argument("--delay", type=float, default=0.0, help="seconds each answer takes")
111
114
  a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
@@ -3,6 +3,7 @@
3
3
  Uses a temporary workspace and tests/mock_server.py on a free local port; no model or GPU needed.
4
4
  """
5
5
  import json
6
+ import math
6
7
  import os
7
8
  import re
8
9
  import socket
@@ -198,6 +199,16 @@ class WorkspaceGraders(unittest.TestCase):
198
199
 
199
200
 
200
201
  class Units(unittest.TestCase):
202
+ def test_server_errors_never_judge_the_model(self):
203
+ from tuieval import client
204
+ no_endpoints = 'server returned HTTP 404: {"error":{"message":"No endpoints found for some/model."}}'
205
+ for reason, retry, unavailable in ((no_endpoints, False, True), ("server returned HTTP 402: no credits", False, True),
206
+ ("server returned HTTP 503: busy", True, False),
207
+ ("server returned HTTP 400: bad request", False, False),
208
+ ("connection error: timed out", False, False)): # the model too slow
209
+ self.assertEqual((client.is_server_error(reason), client.is_unavailable(reason)), (retry, unavailable), reason)
210
+ self.assertEqual(client.server_error_row({"pass": False, "reason": reason}), retry or unavailable, reason)
211
+
201
212
  def test_tune_workload_needs_no_packs(self):
202
213
  from tuieval import tune
203
214
  work = tune.workload(None, 65536)
@@ -692,40 +703,59 @@ class AddToRun(unittest.TestCase):
692
703
 
693
704
 
694
705
  class PTAIndex(unittest.TestCase):
695
- """The PTA index: privacy from where a model runs, time relative to the fastest total, accuracy,
696
- all over the questions every compared model answered."""
697
-
698
- def test_privacy(self):
699
- from tuieval import pta
700
- cfg = {"servers": {"llama": {"cmd": ["llama-server"]}, "lm": {"url": "http://localhost:1234/v1"},
701
- "box": {"url": "http://192.168.1.20:8000/v1"}, "mine": {"url": "http://192.168.1.20:8000/v1",
702
- "private": True},
703
- "or": {"url": "https://openrouter.ai/api/v1"}},
704
- "models": [{"label": l, "server": l} for l in ("llama", "lm", "box", "mine", "or")]}
705
- self.assertEqual([pta.privacy(cfg, l) for l in ("llama", "lm", "box", "mine", "or", "gone")],
706
- [100, 100, 0, 100, 0, None])
706
+ """The PTA index: parsimony from tokens and speed from time, each relative to the best on a log
707
+ scale, and accuracy, all over the questions every compared model answered."""
707
708
 
708
709
  def test_index_compares_shared_questions(self):
709
710
  from tuieval import pta
710
711
 
711
712
  def row(model, test, ok, secs, repeat=0):
712
713
  return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "latency": secs * 1000,
713
- "tokens": 10, "truncated": False, "gen_tps": None, "repeat": repeat}
714
+ "tokens": 10 if model == "slow" else 40, "truncated": False, "gen_tps": None, "repeat": repeat}
714
715
  rows = [row("fast", "q1", True, 2), row("fast", "q2", False, 2),
715
716
  row("slow", "q1", True, 8), row("slow", "q2", True, 4), row("slow", "q2", True, 6, repeat=1),
716
717
  row("slow", "q3", True, 100)] # only slow answered q3: not compared
717
- res = pta.index(rows, lambda m: 100 if m == "slow" else 0)
718
+ res = pta.index(rows)
718
719
  self.assertEqual(res["questions"], 2)
719
720
  by = {x["model"]: x for x in res["models"]}
720
721
  self.assertEqual(by["fast"]["total_s"], 4)
721
722
  self.assertEqual(by["slow"]["total_s"], 13) # q1 8 + q2 median of 4 and 6
722
723
  self.assertEqual(by["fast"]["T"], 100)
723
- self.assertAlmostEqual(by["slow"]["T"], 100 * 4 / 13)
724
+ self.assertAlmostEqual(by["slow"]["T"], 100 - 15 * math.log2(13 / 4)) # 15 points per doubling
725
+ self.assertEqual((by["slow"]["tokens"], by["fast"]["tokens"]), (20, 80)) # medians over repeats
726
+ self.assertEqual((by["slow"]["P"], by["fast"]["P"]), (100, 70)) # 4x the tokens: two doublings
727
+ self.assertEqual((pta.score(2, 1), pta.score(1000, 1)), (85, 0))
728
+ header, table = pta.table(res)
729
+ self.assertEqual(header[1:4], ["P parsimony /100", "T speed /100", "A accuracy %"])
730
+ cells = {r[0]: r for r in table}
731
+ self.assertEqual(cells["fast"][1:4], ["70", "100", "50%"])
732
+ self.assertEqual((cells["slow"][4], cells["fast"][5][-1]), ("20 ★", "★")) # fewest tokens, fastest
733
+ bars = pta.bars(res, 10, markup=False)
734
+ self.assertEqual(bars[0].split(), ["P", "parsimony", "T", "speed", "A", "accuracy"])
735
+ self.assertEqual(bars[2], "fast " + "███████░░░ 70 " + "██████████ 100 " + "█████░░░░░ 50%")
724
736
  self.assertEqual((by["fast"]["A"], by["slow"]["A"]), (50, 100))
725
737
  self.assertEqual([x["model"] for x in res["models"]], ["slow", "fast"]) # best accuracy first
726
- lines = pta.triangle(res, 40)
727
- self.assertIn("P[/b] privacy", lines[0])
728
- self.assertTrue(any("[cyan]" in l for l in lines) and any("[magenta]" in l for l in lines))
738
+
739
+ def test_server_errors_and_models_with_few_answers(self):
740
+ from tuieval import pta
741
+
742
+ def row(model, test, ok=True, error=False):
743
+ return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "error": error,
744
+ "latency": 1000, "tokens": 10, "truncated": False, "gen_tps": None, "repeat": 0}
745
+ rows = ([row(m, f"q{i}") for m in ("a", "b") for i in range(10)] + [row("few", "q0", ok=False)]
746
+ + [row("down", f"q{i}", ok=False, error=True) for i in range(10)]
747
+ + [row("b", "q10", ok=False, error=True)]) # an error among real answers: not counted
748
+ res = pta.index(rows)
749
+ self.assertEqual([x["model"] for x in res["models"]], ["a", "b"])
750
+ self.assertEqual(res["questions"], 10) # not shrunk to the one "few" answered
751
+ self.assertEqual(res["left_out"], [("few", 1)])
752
+ self.assertEqual(res["no_answers"], ["down"])
753
+ self.assertEqual([x["answers"] for x in res["models"]], [10, 10])
754
+ legend = "\n".join(pta.left_out_lines(res))
755
+ self.assertIn("few (1 of 10)", legend)
756
+ self.assertIn("only server errors: down", legend)
757
+ picked = pta.index(rows, ["a", "few"]) # picked by hand: compared anyway
758
+ self.assertEqual((picked["questions"], picked["left_out"]), (1, []))
729
759
 
730
760
  def test_cli_report_and_tui(self):
731
761
  with tempfile.TemporaryDirectory() as tmp:
@@ -749,16 +779,14 @@ class PTAIndex(unittest.TestCase):
749
779
  finally:
750
780
  right.stop()
751
781
  wrong.stop()
752
- path = os.path.join(ws, "models.toml") # as if "there" were a hosted API
753
- write(path, read(path).replace(f"http://127.0.0.1:{wrong.port}", "https://api.example.com/v1"))
754
782
  out = tuieval(ws, "pta").stdout
755
783
  self.assertIn("Compared on the 4 questions all 2 model(s) answered", out)
756
784
  table = {l.split()[0]: l.split()[1:4] for l in out.splitlines() if l.startswith(("good ", "hosted "))}
757
- self.assertEqual(table["good"], ["100", "100", "100"])
758
- self.assertEqual(table["hosted"][0], "0")
785
+ self.assertEqual(table["good"], ["100", "100", "100%"])
786
+ self.assertEqual(table["hosted"][0], "100") # the mock says as much either way
759
787
  self.assertLess(int(table["hosted"][1]), 100) # slower in total
760
- self.assertEqual(table["hosted"][2], "0")
761
- self.assertIn("P privacy", out)
788
+ self.assertEqual(table["hosted"][2], "0%")
789
+ self.assertIn("P parsimony", out)
762
790
  self.assertIn("4/4", tuieval(ws, "pta", "--only", "good").stdout)
763
791
  report = os.path.join(tmp, "r.md")
764
792
  tuieval(ws, "report", "-o", report)
@@ -766,7 +794,7 @@ class PTAIndex(unittest.TestCase):
766
794
  code = textwrap.dedent("""
767
795
  import asyncio, json
768
796
  from tuieval.tui import EvalsApp, ResultsScreen
769
- from textual.widgets import DataTable, Static
797
+ from textual.widgets import DataTable, Static, TabbedContent
770
798
  async def go():
771
799
  app = EvalsApp({})
772
800
  async with app.run_test(size=(180, 60)) as pilot:
@@ -775,16 +803,53 @@ class PTAIndex(unittest.TestCase):
775
803
  await pilot.pause(0.5)
776
804
  t = app.screen.query_one("#pta", DataTable)
777
805
  rows = [[str(c) for c in t.get_row_at(i)] for i in range(t.row_count)]
778
- tri = str(app.screen.query_one("#pta-triangle", Static).render())
779
- print(json.dumps({"rows": rows, "triangle": "P privacy" in tri}))
806
+ tri = str(app.screen.query_one("#pta-bars", Static).render())
807
+ print(json.dumps({"rows": rows, "bars": "P parsimony" in tri,
808
+ "tab": app.screen.query_one(TabbedContent).active}))
780
809
  asyncio.run(go())
781
810
  """)
782
811
  p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
783
812
  env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""))
784
813
  got = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
785
814
  self.assertIsInstance(got, dict, got)
786
- self.assertTrue(got["triangle"])
787
- self.assertEqual([r[:4] for r in got["rows"]][0], ["good", "100", "100", "100"])
815
+ self.assertTrue(got["bars"])
816
+ self.assertEqual(got["tab"], "tab-pta") # Results opens on the PTA index
817
+ self.assertEqual([r[:4] for r in got["rows"]][0], ["good", "100", "100", "100%"])
818
+
819
+
820
+ class Unavailable(unittest.TestCase):
821
+ """A model no server will serve (OpenRouter's 404 "No endpoints found", a bad key, no credits) stops at
822
+ once and is never judged on it; the other models run as usual."""
823
+
824
+ def test_unavailable_model_stops_and_isnt_judged(self):
825
+ with tempfile.TemporaryDirectory() as tmp:
826
+ ws = os.path.join(tmp, "ws")
827
+ tuieval(tmp, "init", ws)
828
+ d = os.path.join(ws, "packs", "apps")
829
+ os.makedirs(d)
830
+ write(os.path.join(d, "pack.toml"), 'label = "apps"\n[certify]\nrepeat = 1\n[gate]\nmin_accuracy = 0.1\n')
831
+ write(os.path.join(d, "tests.yaml"), "".join(
832
+ f"- {{id: {t}, input: question {t}, expected: 1, reference: 'ANSWER: 1', difficulty: easy}}\n"
833
+ for t in "abc"))
834
+ up, down = Mock(os.path.join(ws, "packs"), "oracle"), Mock(os.path.join(ws, "packs"), "unavailable")
835
+ try:
836
+ with open(os.path.join(ws, "models.toml"), "a") as f:
837
+ f.write(f'\n[servers.up]\nurl = "http://127.0.0.1:{up.port}"\n'
838
+ f'\n[servers.down]\nurl = "http://127.0.0.1:{down.port}"\n')
839
+ tuieval(ws, "add", "mock", "--server", "up", "--label", "works")
840
+ tuieval(ws, "add", "mock", "--server", "down", "--label", "gone")
841
+ t = time.time()
842
+ out = tuieval(ws, "run", "--tier", "certify", check=False).stdout
843
+ finally:
844
+ up.stop()
845
+ down.stop()
846
+ self.assertLess(time.time() - t, 60) # no retrying for minutes
847
+ self.assertIn("isn't available there", out)
848
+ verdict = tuieval(ws, "verdict", check=False).stdout
849
+ self.assertNotIn("0% right", verdict) # never judged on the 404s
850
+ rows = [l for l in verdict.splitlines() if "gone" in l]
851
+ self.assertFalse(any("FAIL" in l for l in rows), rows)
852
+ self.assertIn("works", tuieval(ws, "pta").stdout)
788
853
 
789
854
 
790
855
  class FirstRun(unittest.TestCase):
@@ -844,7 +909,7 @@ class FirstRun(unittest.TestCase):
844
909
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
845
910
  self.assertEqual(out, "['first'] ['my-model'] smoke False") # tuning shows once something has run
846
911
 
847
- def test_results_open_on_per_question(self):
912
+ def test_results_open_on_pta_index(self):
848
913
  tuieval(self.ws, "add", "my-model", "--server", "local")
849
914
  code = textwrap.dedent("""
850
915
  import asyncio
@@ -861,7 +926,7 @@ class FirstRun(unittest.TestCase):
861
926
  """)
862
927
  out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
863
928
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
864
- self.assertEqual(out, "ResultsScreen tab-per-question")
929
+ self.assertEqual(out, "ResultsScreen tab-pta")
865
930
 
866
931
  def test_stream_follows_only_at_the_bottom(self):
867
932
  code = textwrap.dedent("""
@@ -1,185 +0,0 @@
1
- """PTA index: privacy, time and accuracy per model, 0-100 each, drawn as a radar triangle.
2
-
3
- P privacy 100 when prompts stay on machines you control: tuieval starts the server, its URL
4
- is this machine, or the server is marked private = true in models.toml. 0 otherwise
5
- (a hosted API: there's no partial privacy once prompts leave).
6
- T time the total time to answer every question compared (each question at its median over
7
- repeats), relative to the fastest model: 100 x fastest total / this model's total.
8
- A accuracy the share of answers to those questions that passed.
9
-
10
- Models are compared on the questions all of them answered, so one that skipped some never looks
11
- faster. There's no combined number: the three stay side by side, and verdicts and critical
12
- failures stay where they are (verdict.py).
13
- """
14
- import urllib.parse
15
-
16
- from . import compare
17
-
18
- LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
19
- # one colour per model on the triangle; more models than colours are listed but not drawn
20
- COLORS = ["cyan", "magenta", "yellow", "green", "red", "blue", "bright_white", "bright_cyan"]
21
-
22
-
23
- def privacy(cfg, label):
24
- """100 if a model's prompts stay on machines you control, else 0 (see the module docstring);
25
- None for a model that's no longer in models.toml (where it ran isn't known)."""
26
- m = next((m for m in cfg["models"] if m["label"] == label), None)
27
- server = cfg["servers"].get(m["server"], {}) if m else {}
28
- if not server:
29
- return None
30
- if server.get("cmd") or server.get("private"):
31
- return 100
32
- host = urllib.parse.urlparse(server.get("url", "")).hostname or ""
33
- return 100 if host.lower() in LOCAL_HOSTS else 0
34
-
35
-
36
- def index(rows, privacy_of, models=None):
37
- """{"questions": n shared, "models": [{model, P, T, A, total_s, passed, answers, alongside}]},
38
- best accuracy first. models: the ones to compare (default: every model in rows)."""
39
- tests = compare.per_question(rows)
40
- models = sorted(models or {m for t in tests for m in t["cells"]})
41
- shared = [t for t in tests if models and all(m in t["cells"] for m in models)]
42
- out = []
43
- for m in models:
44
- cells = [t["cells"][m] for t in shared]
45
- answers = sum(c["n"] for c in cells)
46
- out.append({"model": m, "P": privacy_of(m), "total_s": sum(c["secs"] or 0 for c in cells),
47
- "passed": sum(c["passed"] for c in cells), "answers": answers,
48
- "A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
49
- # answers that ran while other models were served shared the machine's speed
50
- "alongside": any(r.get("alongside") for c in cells for r in c["rows"])})
51
- fastest = min((x["total_s"] for x in out if x["total_s"] > 0), default=None)
52
- for x in out:
53
- x["T"] = 100 * fastest / x["total_s"] if fastest and x["total_s"] > 0 else None
54
- out.sort(key=lambda x: (-(x["A"] or 0), -(x["T"] or 0), x["model"]))
55
- return {"questions": len(shared), "models": out}
56
-
57
-
58
- # ---------------------------------------------------------------- the triangle (braille, no dependencies)
59
- _DOTS = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80)) # braille dot bits by (row, column)
60
-
61
-
62
- class _Canvas:
63
- """Braille cells, 2 dots wide and 4 tall each; every cell takes the colour of the last line through it."""
64
- def __init__(self, cols, rows):
65
- self.cols, self.rows = cols, rows
66
- self.bits = [[0] * cols for _ in range(rows)]
67
- self.color = [[None] * cols for _ in range(rows)]
68
-
69
- def dot(self, x, y, color):
70
- cx, cy = int(x) // 2, int(y) // 4
71
- if 0 <= cx < self.cols and 0 <= cy < self.rows:
72
- self.bits[cy][cx] |= _DOTS[int(y) % 4][int(x) % 2]
73
- self.color[cy][cx] = color
74
-
75
- def line(self, a, b, color, dotted=False):
76
- steps = int(max(abs(b[0] - a[0]), abs(b[1] - a[1]))) or 1
77
- for i in range(steps + 1):
78
- if dotted and i % 3:
79
- continue
80
- self.dot(a[0] + (b[0] - a[0]) * i / steps, a[1] + (b[1] - a[1]) * i / steps, color)
81
-
82
- def text(self):
83
- lines = []
84
- for bits, colors in zip(self.bits, self.color):
85
- out = []
86
- for b, c in zip(bits, colors):
87
- ch = chr(0x2800 + b) if b else " "
88
- out.append(f"[{c}]{ch}[/]" if b and c else ch)
89
- lines.append("".join(out).rstrip())
90
- return lines
91
-
92
-
93
- def triangle(result, width=60):
94
- """The radar triangle as Rich markup lines: P at the top, T bottom left, A bottom right; a
95
- model's triangle reaches each corner as far as its score (bigger = better all round)."""
96
- cols = max(20, width)
97
- w = cols * 2 - 2 # dots across
98
- h = int(w * 0.866) # an equilateral triangle (braille dots are about square)
99
- rows = h // 4 + 1
100
- top, left, right = (w / 2, 0), (0, h), (w, h)
101
- center = (w / 2, h * 2 / 3)
102
- c = _Canvas(cols, rows)
103
-
104
- def at(corner, score):
105
- return (center[0] + (corner[0] - center[0]) * score / 100, center[1] + (corner[1] - center[1]) * score / 100)
106
-
107
- for corner in (top, left, right): # axes, the 50% ring, then the frame
108
- c.line(center, corner, "grey37", dotted=True)
109
- half = [at(k, 50) for k in (top, left, right)]
110
- for a, b in zip(half, half[1:] + half[:1]):
111
- c.line(a, b, "grey37", dotted=True)
112
- for a, b in ((top, left), (left, right), (right, top)):
113
- c.line(a, b, "grey70")
114
- for i, x in enumerate(result["models"][:len(COLORS)][::-1]): # the best ends on top
115
- color = COLORS[len(result["models"][:len(COLORS)]) - 1 - i]
116
- pts = [at(k, s or 0) for k, s in ((top, x["P"]), (left, x["T"]), (right, x["A"]))]
117
- for a, b in zip(pts, pts[1:] + pts[:1]):
118
- c.line(a, b, color)
119
- lines = c.text()
120
- pad = " " * max(0, cols // 2 - 5)
121
- return ([f"{pad}[b]P[/b] privacy"] + lines
122
- + [f"[b]T[/b] time{' ' * max(1, cols - 15)}[b]A[/b] accuracy"])
123
-
124
-
125
- def legend(result):
126
- """One Rich markup line per model: its colour on the triangle and its three scores."""
127
- out = []
128
- for i, x in enumerate(result["models"]):
129
- mark = f"[{COLORS[i]}]■[/]" if i < len(COLORS) else " "
130
- out.append(f"{mark} {x['model']} P {_score(x['P'])} · T {_score(x['T'])} · A {_score(x['A'])}"
131
- + (" [yellow](some answers ran alongside other models)[/yellow]" if x["alongside"] else ""))
132
- return out
133
-
134
-
135
- def _score(v):
136
- return "-" if v is None else f"{v:.0f}"
137
-
138
-
139
- def table(result):
140
- """(header, rows) for a plain table of the index."""
141
- header = ["model", "P privacy", "T time", "A accuracy", "total time", "answers right"]
142
- rows = [[x["model"], _score(x["P"]), _score(x["T"]), _score(x["A"]), fmt_total(x["total_s"]),
143
- f"{x['passed']}/{x['answers']}"] for x in result["models"]]
144
- return header, rows
145
-
146
-
147
- def fmt_total(secs):
148
- if not secs:
149
- return "-"
150
- if secs < 60:
151
- return f"{secs:.1f}s"
152
- secs = round(secs)
153
- h, rem = divmod(secs, 3600)
154
- return f"{h}h{rem // 60:02d}m" if h else f"{rem // 60}m{rem % 60:02d}s" if rem >= 60 else f"{rem}s"
155
-
156
-
157
- def scope_note(result, models):
158
- """What the scores compare, in a sentence."""
159
- if not result["questions"]:
160
- return "These models have no question in common yet, so there's nothing to compare."
161
- return (f"Compared on the {result['questions']} questions all {len(models)} model(s) answered. "
162
- "T is relative to the fastest total, so adding a faster model lowers the others.")
163
-
164
-
165
- def for_engine(e, labels=None, packs=None, results_dir=None):
166
- """(result, models) from a workspace's finished results (results on their way out excluded)."""
167
- paths = [p for p in compare.default_paths(results_dir or e.results_dir) if not e.superseded(p)]
168
- rows, _ = compare.load(paths) if paths else ([], [])
169
- if packs:
170
- rows = [r for r in rows if r["suite"] in packs]
171
- models = sorted({r["model"] for r in rows} & set(labels) if labels else {r["model"] for r in rows})
172
- return index(rows, lambda m: privacy(e.cfg, m), models), models
173
-
174
-
175
- def markdown(result, models):
176
- """The index as a markdown section (tuieval report)."""
177
- lines = ["## PTA index", "", scope_note(result, models), ""]
178
- if not result["questions"]:
179
- return lines
180
- header, rows = table(result)
181
- lines += ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
182
- lines += ["| " + " | ".join(r) + " |" for r in rows]
183
- if any(x["alongside"] for x in result["models"]):
184
- lines += ["", "Some answers ran alongside other models, which shared the machine's speed."]
185
- return lines
File without changes
File without changes
File without changes