tuieval 0.2.0.dev43__tar.gz → 0.2.0.dev55__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.dev43 → tuieval-0.2.0.dev55}/CHANGELOG.md +2 -1
  2. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/models.md +5 -6
  4. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/cli.py +1 -1
  6. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/machines.py +4 -4
  7. tuieval-0.2.0.dev55/src/tuieval/pta.py +176 -0
  8. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/run_evals.py +9 -4
  9. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/tui.py +32 -33
  10. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/watch_proxy.py +7 -0
  11. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/tests/mock_server.py +9 -1
  12. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/tests/test_tuieval.py +51 -31
  13. tuieval-0.2.0.dev43/src/tuieval/pta.py +0 -185
  14. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/.gitignore +0 -0
  15. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/LICENSE +0 -0
  16. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/README.md +0 -0
  17. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/RELEASING.md +0 -0
  18. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/README.md +0 -0
  19. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/favicon.ico +0 -0
  20. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/readme-header.png +0 -0
  21. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/social-preview.png +0 -0
  22. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-1024.png +0 -0
  23. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-128.png +0 -0
  24. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-16.png +0 -0
  25. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-16.svg +0 -0
  26. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-256.png +0 -0
  27. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-32.png +0 -0
  28. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-48.png +0 -0
  29. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-512.png +0 -0
  30. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-64.png +0 -0
  31. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  32. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/brand/tuieval-icon.svg +0 -0
  33. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/tui-answer-reasoning.png +0 -0
  34. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/tui-run-reasoning.png +0 -0
  35. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/images/tui-setup.png +0 -0
  36. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/docs/writing-packs.md +0 -0
  37. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/pyproject.toml +0 -0
  38. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/__init__.py +0 -0
  39. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/__main__.py +0 -0
  40. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/builtin.toml +0 -0
  41. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/client.py +0 -0
  42. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/compare.py +0 -0
  43. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/doctor.py +0 -0
  44. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/engine.py +0 -0
  45. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/export.py +0 -0
  46. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/__init__.py +0 -0
  47. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/answer.py +0 -0
  48. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/code.py +0 -0
  49. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/rag.py +0 -0
  50. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/reply.py +0 -0
  51. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/graders/tool_call.py +0 -0
  52. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/onboard.py +0 -0
  53. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/packs.py +0 -0
  54. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/profiles.py +0 -0
  55. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/remove.py +0 -0
  56. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/scaffold.py +0 -0
  57. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/selftest.py +0 -0
  58. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/models.toml +0 -0
  59. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  60. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/answer/system.txt +0 -0
  61. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  62. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/code/pack.toml +0 -0
  63. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/code/system.txt +0 -0
  64. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  65. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  66. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/rag/system.txt +0 -0
  67. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  68. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  69. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/reply/system.txt +0 -0
  70. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  71. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  72. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  73. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  74. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  75. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/tune.py +0 -0
  76. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/verdict.py +0 -0
  77. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev43 → tuieval-0.2.0.dev55}/src/tuieval/yamlout.py +0 -0
@@ -2,8 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Faster start on Macs: the GPU core count comes from `ioreg` instead of `system_profiler`, which took seconds on some Macs.
5
6
  - 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: privacy, time (total, relative to the fastest) and accuracy per model, as a triangle in Results, `tuieval pta` and the report.
7
+ - 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.
7
8
  - 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.
8
9
  - More evals during a run: `n` opens Setup, and Start adds them to the run going (same tier) or queues them after it.
9
10
  - 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.dev43
3
+ Version: 0.2.0.dev55
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.dev43'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev43')
21
+ __version__ = version = '0.2.0.dev55'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev55')
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
@@ -65,10 +65,10 @@ def detect():
65
65
  # macOS default when unset: about 2/3 of RAM up to 36 GB, 3/4 above.
66
66
  limit = wired / 1024 if wired > 0 else ram * (0.75 if ram > 36 else 2 / 3)
67
67
  gpu_cores = None
68
- try:
69
- out = subprocess.run(["system_profiler", "SPDisplaysDataType"], capture_output=True, text=True,
70
- timeout=15).stdout
71
- m = re.search(r"Total Number of Cores:\s*(\d+)", out)
68
+ try: # the GPU driver's own count: instant, where system_profiler can take seconds (CI's virtual Macs)
69
+ out = subprocess.run(["ioreg", "-rc", "AGXAccelerator", "-d1"], capture_output=True, text=True,
70
+ timeout=5).stdout
71
+ m = re.search(r'"gpu-core-count"\s*=\s*(\d+)', out)
72
72
  gpu_cores = int(m.group(1)) if m else None
73
73
  except (OSError, subprocess.TimeoutExpired):
74
74
  pass
@@ -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; }
@@ -18,6 +18,7 @@ import http.client
18
18
  import itertools
19
19
  import json
20
20
  import os
21
+ import socketserver
21
22
  import sys
22
23
  import threading
23
24
  import time
@@ -129,6 +130,12 @@ class Proxy:
129
130
  class Server(ThreadingHTTPServer):
130
131
  daemon_threads = True
131
132
 
133
+ def server_bind(self):
134
+ # HTTPServer looks up its own hostname here (socket.getfqdn), which can take half a
135
+ # minute where DNS is slow; the name is only cosmetic, so use the address.
136
+ socketserver.TCPServer.server_bind(self)
137
+ self.server_name, self.server_port = self.server_address[:2]
138
+
132
139
  def handle_error(self, request, client_address):
133
140
  err = sys.exc_info()[1]
134
141
  if isinstance(err, (BrokenPipeError, ConnectionResetError)):
@@ -15,6 +15,7 @@ import json
15
15
  import os
16
16
  import sys
17
17
  import time
18
+ import socketserver
18
19
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
20
 
20
21
  import yaml
@@ -104,6 +105,13 @@ class Handler(BaseHTTPRequestHandler):
104
105
  self.wfile.write(b"data: [DONE]\n\n")
105
106
 
106
107
 
108
+ class Server(ThreadingHTTPServer):
109
+ def server_bind(self):
110
+ # skip HTTPServer's own-hostname lookup (socket.getfqdn): 35 s per server on CI's macOS runners
111
+ socketserver.TCPServer.server_bind(self)
112
+ self.server_name, self.server_port = self.server_address[:2]
113
+
114
+
107
115
  def main(argv=None):
108
116
  p = argparse.ArgumentParser()
109
117
  p.add_argument("--port", type=int, default=18090)
@@ -113,7 +121,7 @@ def main(argv=None):
113
121
  p.add_argument("--delay", type=float, default=0.0, help="seconds each answer takes")
114
122
  a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
115
123
  Handler.answers, Handler.mode, Handler.model, Handler.delay = load_answers(a.packs), a.mode, a.model, a.delay
116
- srv = ThreadingHTTPServer(("127.0.0.1", a.port), Handler)
124
+ srv = Server(("127.0.0.1", a.port), Handler)
117
125
  print(f"mock server on 127.0.0.1:{a.port} ({a.mode}, {len(Handler.answers)} known questions)", flush=True)
118
126
  try:
119
127
  srv.serve_forever()
@@ -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
@@ -702,40 +703,59 @@ class AddToRun(unittest.TestCase):
702
703
 
703
704
 
704
705
  class PTAIndex(unittest.TestCase):
705
- """The PTA index: privacy from where a model runs, time relative to the fastest total, accuracy,
706
- all over the questions every compared model answered."""
707
-
708
- def test_privacy(self):
709
- from tuieval import pta
710
- cfg = {"servers": {"llama": {"cmd": ["llama-server"]}, "lm": {"url": "http://localhost:1234/v1"},
711
- "box": {"url": "http://192.168.1.20:8000/v1"}, "mine": {"url": "http://192.168.1.20:8000/v1",
712
- "private": True},
713
- "or": {"url": "https://openrouter.ai/api/v1"}},
714
- "models": [{"label": l, "server": l} for l in ("llama", "lm", "box", "mine", "or")]}
715
- self.assertEqual([pta.privacy(cfg, l) for l in ("llama", "lm", "box", "mine", "or", "gone")],
716
- [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."""
717
708
 
718
709
  def test_index_compares_shared_questions(self):
719
710
  from tuieval import pta
720
711
 
721
712
  def row(model, test, ok, secs, repeat=0):
722
713
  return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "latency": secs * 1000,
723
- "tokens": 10, "truncated": False, "gen_tps": None, "repeat": repeat}
714
+ "tokens": 10 if model == "slow" else 40, "truncated": False, "gen_tps": None, "repeat": repeat}
724
715
  rows = [row("fast", "q1", True, 2), row("fast", "q2", False, 2),
725
716
  row("slow", "q1", True, 8), row("slow", "q2", True, 4), row("slow", "q2", True, 6, repeat=1),
726
717
  row("slow", "q3", True, 100)] # only slow answered q3: not compared
727
- res = pta.index(rows, lambda m: 100 if m == "slow" else 0)
718
+ res = pta.index(rows)
728
719
  self.assertEqual(res["questions"], 2)
729
720
  by = {x["model"]: x for x in res["models"]}
730
721
  self.assertEqual(by["fast"]["total_s"], 4)
731
722
  self.assertEqual(by["slow"]["total_s"], 13) # q1 8 + q2 median of 4 and 6
732
723
  self.assertEqual(by["fast"]["T"], 100)
733
- 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%")
734
736
  self.assertEqual((by["fast"]["A"], by["slow"]["A"]), (50, 100))
735
737
  self.assertEqual([x["model"] for x in res["models"]], ["slow", "fast"]) # best accuracy first
736
- lines = pta.triangle(res, 40)
737
- self.assertIn("P[/b] privacy", lines[0])
738
- 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, []))
739
759
 
740
760
  def test_cli_report_and_tui(self):
741
761
  with tempfile.TemporaryDirectory() as tmp:
@@ -759,16 +779,14 @@ class PTAIndex(unittest.TestCase):
759
779
  finally:
760
780
  right.stop()
761
781
  wrong.stop()
762
- path = os.path.join(ws, "models.toml") # as if "there" were a hosted API
763
- write(path, read(path).replace(f"http://127.0.0.1:{wrong.port}", "https://api.example.com/v1"))
764
782
  out = tuieval(ws, "pta").stdout
765
783
  self.assertIn("Compared on the 4 questions all 2 model(s) answered", out)
766
784
  table = {l.split()[0]: l.split()[1:4] for l in out.splitlines() if l.startswith(("good ", "hosted "))}
767
- self.assertEqual(table["good"], ["100", "100", "100"])
768
- 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
769
787
  self.assertLess(int(table["hosted"][1]), 100) # slower in total
770
- self.assertEqual(table["hosted"][2], "0")
771
- self.assertIn("P privacy", out)
788
+ self.assertEqual(table["hosted"][2], "0%")
789
+ self.assertIn("P parsimony", out)
772
790
  self.assertIn("4/4", tuieval(ws, "pta", "--only", "good").stdout)
773
791
  report = os.path.join(tmp, "r.md")
774
792
  tuieval(ws, "report", "-o", report)
@@ -776,7 +794,7 @@ class PTAIndex(unittest.TestCase):
776
794
  code = textwrap.dedent("""
777
795
  import asyncio, json
778
796
  from tuieval.tui import EvalsApp, ResultsScreen
779
- from textual.widgets import DataTable, Static
797
+ from textual.widgets import DataTable, Static, TabbedContent
780
798
  async def go():
781
799
  app = EvalsApp({})
782
800
  async with app.run_test(size=(180, 60)) as pilot:
@@ -785,16 +803,18 @@ class PTAIndex(unittest.TestCase):
785
803
  await pilot.pause(0.5)
786
804
  t = app.screen.query_one("#pta", DataTable)
787
805
  rows = [[str(c) for c in t.get_row_at(i)] for i in range(t.row_count)]
788
- tri = str(app.screen.query_one("#pta-triangle", Static).render())
789
- 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}))
790
809
  asyncio.run(go())
791
810
  """)
792
811
  p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=300,
793
812
  env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""))
794
813
  got = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
795
814
  self.assertIsInstance(got, dict, got)
796
- self.assertTrue(got["triangle"])
797
- 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%"])
798
818
 
799
819
 
800
820
  class Unavailable(unittest.TestCase):
@@ -889,7 +909,7 @@ class FirstRun(unittest.TestCase):
889
909
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
890
910
  self.assertEqual(out, "['first'] ['my-model'] smoke False") # tuning shows once something has run
891
911
 
892
- def test_results_open_on_per_question(self):
912
+ def test_results_open_on_pta_index(self):
893
913
  tuieval(self.ws, "add", "my-model", "--server", "local")
894
914
  code = textwrap.dedent("""
895
915
  import asyncio
@@ -906,7 +926,7 @@ class FirstRun(unittest.TestCase):
906
926
  """)
907
927
  out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
908
928
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
909
- self.assertEqual(out, "ResultsScreen tab-per-question")
929
+ self.assertEqual(out, "ResultsScreen tab-pta")
910
930
 
911
931
  def test_stream_follows_only_at_the_bottom(self):
912
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