tuieval 0.2.0.dev92__tar.gz → 0.2.0.dev94__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.
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/CHANGELOG.md +1 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/PKG-INFO +1 -1
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/models.md +16 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/cli.py +1 -0
- tuieval-0.2.0.dev94/src/tuieval/insights.py +538 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/run_evals.py +37 -1
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/tui.py +63 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/tests/test_tuieval.py +123 -1
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/.gitignore +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/LICENSE +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/README.md +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/builtin.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/client.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/compare.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/engine.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/logs.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/pta.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/yamlout.py +0 -0
- {tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/tests/mock_server.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Results → Insights (also `tuieval insights`): charts with what to do next: every model against a reference (95% CI), the best model per use case, fine-tunes against their base (`base` in models.toml), why models fail, accuracy against tokens or time, question health, and the Certify runs that would settle the strongest models' verdicts. `[defaults] reference` sets the default reference.
|
|
5
6
|
- A server that stops sending tokens mid-answer for 5 minutes (`stall_timeout_ms`) is a server error: retried, never counted against the model. Keep-alive pings no longer stretch a request past `request_timeout_ms`.
|
|
6
7
|
- The run screen's queue shows what each running model is doing: waiting, thinking or answering with ~tokens so far, and how long it's been quiet, so a model you aren't streaming doesn't look stuck.
|
|
7
8
|
- Results, Per question: the side-by-side summary shows any number of models (it stopped at 8).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev94
|
|
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
|
|
@@ -40,6 +40,7 @@ In the TUI, `a` adds a model and `m` scans your model folders. Each model is one
|
|
|
40
40
|
| `max_context` (other servers) | the context the server provides; packs whose prompts need more are skipped |
|
|
41
41
|
| `kv_type` (llama) | e.g. `"q8_0"`: a quantized KV cache, which makes it a different model (give it its own label) |
|
|
42
42
|
| `tools = false` | packs that need tool calling are skipped |
|
|
43
|
+
| `base` | for a fine-tune: the label of its base model, compared in Results → Insights |
|
|
43
44
|
|
|
44
45
|
Results record the settings each model actually ran with, and the results screen notes any differences between models.
|
|
45
46
|
|
|
@@ -109,6 +110,20 @@ The P and T bars are as long as the tokens and time used: the model that used th
|
|
|
109
110
|
|
|
110
111
|
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.
|
|
111
112
|
|
|
113
|
+
## Insights
|
|
114
|
+
|
|
115
|
+
Results → **Insights** (also `tuieval insights`) turns the results into charts, each with plain lines on what to do:
|
|
116
|
+
|
|
117
|
+
1. **Who's worth keeping**: every model against a reference model, paired on the questions both answered: the difference in accuracy points with its 95% CI, and better, tie or worse. Pick the reference in the tab or with `--reference`; set a default with `reference = "<label>"` under `[defaults]` in `models.toml`. Without one, the most accurate model is the reference.
|
|
118
|
+
2. **Best model per use case**: a heatmap of accuracy per model and use case (pack group); ★ marks the best among models that answered most of it.
|
|
119
|
+
3. **Fine-tunes against their base model**: for each model with `base = "<label>"` in its `[[models]]` block, the difference per pack and the question categories (a test's `category`) it gained or lost most.
|
|
120
|
+
4. **Why models fail**: each model's answers split into right, wrong, cut off at max_tokens, no answer or bad format, code that crashed or hung, and critical failures.
|
|
121
|
+
5. **Accuracy against cost**: accuracy against median tokens (or seconds) per question, over the questions every model answered; green models are the ones no other model beats on both. With hosted and local models together, the best local trade-offs get their own line.
|
|
122
|
+
6. **Question health**: questions every model fails (check them), every model passes (they don't separate models), and the ones that split models most.
|
|
123
|
+
7. **Next runs**: the Certify runs that would settle the open verdicts of the strongest models, with their time and command.
|
|
124
|
+
|
|
125
|
+
Models are the ones picked with **Models** (shared with the other tabs), else every model with at least half as many answers as the most-answered one. Server and connection errors aren't answers.
|
|
126
|
+
|
|
112
127
|
## Tuning
|
|
113
128
|
|
|
114
129
|
`tuieval tune <model>` (or `t` on the setup screen, for the ticked models) finds the fastest speed flags for a model on this machine:
|
|
@@ -192,6 +207,7 @@ A workspace's `models.toml` only needs what differs from these. `[defaults]` fil
|
|
|
192
207
|
repeat = 3
|
|
193
208
|
request_timeout_ms = 1800000 # per request; long thinking runs need it
|
|
194
209
|
stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
|
|
210
|
+
# reference = "<label>" # the model Results → Insights compares against by default
|
|
195
211
|
ready_timeout_s = 1200 # how long a model may take to load before giving up
|
|
196
212
|
model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
|
|
197
213
|
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
21
|
+
__version__ = version = '0.2.0.dev94'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev94')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -15,6 +15,7 @@ Start here
|
|
|
15
15
|
Day to day
|
|
16
16
|
tuieval compare [...] scorecards (--speed, --pairwise, --reference M, --failures)
|
|
17
17
|
tuieval pta the PTA index: parsimony (tokens), time and accuracy per model, as bars
|
|
18
|
+
tuieval insights charts with what to do next: vs a reference, per use case, why models fail, …
|
|
18
19
|
tuieval report the verdicts with their evidence, as a markdown file
|
|
19
20
|
tuieval history every verdict change, newest first
|
|
20
21
|
tuieval items tests that don't separate models or look broken
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Insights: what the results say to do next, as a chart per question with plain action lines under it
|
|
2
|
+
(Results → Insights, `tuieval insights`).
|
|
3
|
+
|
|
4
|
+
1 vs reference each model against one reference model, paired on the questions both answered:
|
|
5
|
+
accuracy points with a 95% CI, better / tie / worse
|
|
6
|
+
2 use cases accuracy per model and use case (pack group) as a heatmap; the best marked
|
|
7
|
+
3 vs base a fine-tune against its base model (models.toml `base`): per pack, and the question
|
|
8
|
+
categories it gained or lost most
|
|
9
|
+
4 why they fail each model's answers split into right, wrong, cut off, no answer / bad format, code
|
|
10
|
+
crashed or hung, and critical failures
|
|
11
|
+
5 accuracy / cost accuracy against median tokens (or seconds) per question; the models no other beats
|
|
12
|
+
on both
|
|
13
|
+
6 questions questions every model fails (check them), every model passes (no signal), and the
|
|
14
|
+
ones that split models most
|
|
15
|
+
7 next runs the Certify runs that would settle the open verdicts of the strongest models, with
|
|
16
|
+
their time and command
|
|
17
|
+
|
|
18
|
+
Every panel returns {"title", "lines", "actions"}: lines are Rich markup (the chart), actions plain
|
|
19
|
+
sentences. Server and connection errors are never answers here either.
|
|
20
|
+
"""
|
|
21
|
+
import collections
|
|
22
|
+
import math
|
|
23
|
+
import statistics
|
|
24
|
+
|
|
25
|
+
from rich.markup import escape as rich_escape
|
|
26
|
+
|
|
27
|
+
from . import compare
|
|
28
|
+
|
|
29
|
+
MIN_SHARE = 0.5 # like the PTA index: models with fewer answers than this share of the most-answered are left out
|
|
30
|
+
HALF = 15 # half width of a diverging bar
|
|
31
|
+
COVERED = 0.8 # a use case counts as covered when a model answered this share of its questions
|
|
32
|
+
MAX_ACTIONS = 6
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _esc(s):
|
|
36
|
+
return rich_escape(str(s))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def prepare(rows, picked=None, ref=None):
|
|
40
|
+
"""(rows without errors, the models to show): picked ones, else every model with enough answers;
|
|
41
|
+
the reference is always shown when it has answers."""
|
|
42
|
+
rows = [r for r in rows if not r.get("error")]
|
|
43
|
+
counts = collections.Counter(r["model"] for r in rows)
|
|
44
|
+
if picked:
|
|
45
|
+
models = sorted(m for m in picked if m in counts)
|
|
46
|
+
else:
|
|
47
|
+
most = max(counts.values(), default=0)
|
|
48
|
+
models = sorted(m for m, n in counts.items() if n >= MIN_SHARE * most)
|
|
49
|
+
if ref in counts and ref not in models:
|
|
50
|
+
models.append(ref)
|
|
51
|
+
return [r for r in rows if r["model"] in models], models
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_reference(rows, models, configured=None):
|
|
55
|
+
"""models.toml [defaults] reference when it has answers, else the most accurate model shown."""
|
|
56
|
+
if configured in models:
|
|
57
|
+
return configured
|
|
58
|
+
acc = {m: statistics.fmean(r["ok"] for r in rows if r["model"] == m) for m in models
|
|
59
|
+
if any(r["model"] == m for r in rows)}
|
|
60
|
+
return max(acc, key=lambda m: (acc[m], m), default=None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _pts(x):
|
|
64
|
+
return f"{100 * x:+.1f}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def diverging(diff, lo, hi, scale, color):
|
|
68
|
+
"""A bar from the centre line to diff, the CI around it as a thin line: -scale .. +scale."""
|
|
69
|
+
out = []
|
|
70
|
+
for i in range(2 * HALF + 1):
|
|
71
|
+
v = (i - HALF) / HALF * scale
|
|
72
|
+
step = scale / HALF / 2
|
|
73
|
+
filled = (0 < v <= diff + step) if diff > 0 else (diff - step <= v < 0) if diff < 0 else False
|
|
74
|
+
if i == HALF:
|
|
75
|
+
out.append(f"[{color}]█[/]" if filled else "│")
|
|
76
|
+
elif filled:
|
|
77
|
+
out.append(f"[{color}]█[/]")
|
|
78
|
+
elif lo - step <= v <= hi + step:
|
|
79
|
+
out.append("[#808080]─[/]")
|
|
80
|
+
else:
|
|
81
|
+
out.append(" ")
|
|
82
|
+
return "".join(out)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
VERDICT_COLOR = {"better": "green", "worse": "red", "tie": "yellow"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _vs_lines(results, name_of=lambda x: x["model"]):
|
|
89
|
+
if not results:
|
|
90
|
+
return []
|
|
91
|
+
scale = max(0.05, *(max(abs(x["lo"]), abs(x["hi"]), abs(x["diff"])) for x in results))
|
|
92
|
+
w = max(len(name_of(x)) for x in results)
|
|
93
|
+
lines = [" " * (w + 2) + f"[dim]{_pts(-scale)} pts" + " " * (2 * HALF - 16) + f"{_pts(scale)} pts[/dim]"]
|
|
94
|
+
for x in results:
|
|
95
|
+
color = VERDICT_COLOR[x["verdict"]]
|
|
96
|
+
lines.append(f"{_esc(name_of(x)):<{w}} {diverging(x['diff'], x['lo'], x['hi'], scale, color)} "
|
|
97
|
+
f"[{color}]{_pts(x['diff'])} pts[/] [dim]({_pts(x['lo'])} to {_pts(x['hi'])}, "
|
|
98
|
+
f"{x['n']} q)[/dim] [{color}]{x['verdict'].upper()}[/]")
|
|
99
|
+
return lines
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- 1 vs reference
|
|
103
|
+
def vs_reference(rows, models, ref):
|
|
104
|
+
title = "Who's worth keeping: accuracy against the reference"
|
|
105
|
+
if not ref:
|
|
106
|
+
return {"title": title, "lines": ["[dim]No reference model with answers.[/dim]"], "actions": []}
|
|
107
|
+
res = sorted((x for x in compare.vs_reference(rows, ref) if x["pack"] == "all" and x["model"] in models),
|
|
108
|
+
key=lambda x: -x["diff"])
|
|
109
|
+
acc = statistics.fmean(r["ok"] for r in rows if r["model"] == ref) if any(r["model"] == ref for r in rows) else 0
|
|
110
|
+
lines = [f"[dim]Reference: [b]{_esc(ref)}[/b] ({compare.pct(acc)} right). Paired on the questions both "
|
|
111
|
+
"answered; the bar is the difference in points, the thin line its 95% CI.[/dim]"] + _vs_lines(res)
|
|
112
|
+
actions = []
|
|
113
|
+
for x in res:
|
|
114
|
+
if x["verdict"] == "better":
|
|
115
|
+
actions.append(f"{x['model']} beats {ref} by {100 * x['diff']:.1f} pts on {x['n']} shared questions.")
|
|
116
|
+
worse = [x for x in res if x["verdict"] == "worse"]
|
|
117
|
+
if worse:
|
|
118
|
+
actions.append(f"Behind {ref} for certain: " + ", ".join(f"{x['model']} ({_pts(x['diff'])})" for x in worse[:5])
|
|
119
|
+
+ (f" and {len(worse) - 5} more" if len(worse) > 5 else "") + ": candidates to drop.")
|
|
120
|
+
same = [x for x in res if x["verdict"] == "tie" and x["lo"] == x["hi"] == 0]
|
|
121
|
+
if same:
|
|
122
|
+
actions.append("Same result as " + ref + " on every shared question: " + ", ".join(x["model"] for x in same)
|
|
123
|
+
+ " (harder questions would separate them).")
|
|
124
|
+
ties = [x for x in res if x["verdict"] == "tie" and x not in same]
|
|
125
|
+
if ties:
|
|
126
|
+
actions.append("Too close to call against " + ref + ": " + ", ".join(
|
|
127
|
+
f"{x['model']} ({_pts(x['diff'])}, CI {_pts(x['lo'])} to {_pts(x['hi'])})" for x in ties[:4])
|
|
128
|
+
+ ": more questions or a Certify run would decide.")
|
|
129
|
+
return {"title": title, "lines": lines, "actions": actions}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- 2 use cases
|
|
133
|
+
HEAT = ((0.9, "bold green"), (0.75, "bold yellow"), (0.5, "bold #ff8c00"), (0.0, "bold red"))
|
|
134
|
+
CELL = 12
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _heat(acc):
|
|
138
|
+
return next(style for floor, style in HEAT if acc >= floor)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def use_cases(rows, models, packs):
|
|
142
|
+
"""packs: {name: Pack} (for groups and question counts)."""
|
|
143
|
+
title = "Best model per use case"
|
|
144
|
+
groups = []
|
|
145
|
+
for name, p in packs.items():
|
|
146
|
+
if p.group not in groups:
|
|
147
|
+
groups.append(p.group)
|
|
148
|
+
groups = [g for g in groups if any(packs.get(r["suite"]) and packs[r["suite"]].group == g for r in rows)]
|
|
149
|
+
if not groups:
|
|
150
|
+
return {"title": title, "lines": ["[dim]No results yet.[/dim]"], "actions": []}
|
|
151
|
+
size = {g: sum(len(p.tests) for p in packs.values() if p.group == g) for g in groups}
|
|
152
|
+
per = collections.defaultdict(lambda: collections.defaultdict(list)) # (model, group) -> test -> oks
|
|
153
|
+
for r in rows:
|
|
154
|
+
if r["suite"] in packs:
|
|
155
|
+
per[r["model"], packs[r["suite"]].group][r["test"]].append(r["ok"])
|
|
156
|
+
cell = {}
|
|
157
|
+
for m in models:
|
|
158
|
+
for g in groups:
|
|
159
|
+
tests = per.get((m, g))
|
|
160
|
+
if tests:
|
|
161
|
+
cell[m, g] = (statistics.fmean(statistics.fmean(v) for v in tests.values()), len(tests) / max(1, size[g]))
|
|
162
|
+
best = {}
|
|
163
|
+
for g in groups:
|
|
164
|
+
full = {m: a for (m, gg), (a, cov) in cell.items() if gg == g and cov >= COVERED}
|
|
165
|
+
if full:
|
|
166
|
+
top = max(full.values())
|
|
167
|
+
best[g] = (sorted((m for m, a in full.items() if a == top)), sorted(full.items(), key=lambda x: -x[1]))
|
|
168
|
+
w = max(len(m) for m in models)
|
|
169
|
+
short = {g: (g if len(g) <= CELL - 1 else f"{groups.index(g) + 1} {g[:CELL - 4]}…") for g in groups}
|
|
170
|
+
lines = [" " * (w + 2) + "".join(f"[b]{short[g]:<{CELL}}[/b]" for g in groups)]
|
|
171
|
+
for m in models:
|
|
172
|
+
cells = []
|
|
173
|
+
for g in groups:
|
|
174
|
+
if (m, g) not in cell:
|
|
175
|
+
cells.append(f"[#808080]{'-':^{CELL - 1}}[/] ")
|
|
176
|
+
continue
|
|
177
|
+
a, cov = cell[m, g]
|
|
178
|
+
star = "★" if g in best and m in best[g][0] else " "
|
|
179
|
+
text = f"██ {100 * a:>3.0f}%{'◔' if cov < COVERED else ' '}{star}"
|
|
180
|
+
cells.append(f"[{_heat(a)}]{text:<{CELL - 1}}[/] ")
|
|
181
|
+
lines.append(f"{_esc(m):<{w}} " + "".join(cells))
|
|
182
|
+
lines.append("[dim]★ best (among models that answered most of it) · ◔ answered under "
|
|
183
|
+
f"{100 * COVERED:.0f}% of its questions ·[/dim] " + " ".join(f"[{st}]██[/] [dim]{lbl}[/dim]" for (_, st), lbl in
|
|
184
|
+
zip(HEAT, ("90%+", "75%+", "50%+", "under 50%"))))
|
|
185
|
+
if any(short[g] != g for g in groups):
|
|
186
|
+
lines.append("[dim]" + " · ".join(f"{i + 1} {g}" for i, g in enumerate(groups) if short[g] != g) + "[/dim]")
|
|
187
|
+
actions = []
|
|
188
|
+
for g in groups:
|
|
189
|
+
if g not in best:
|
|
190
|
+
actions.append(f"{g}: no model has answered most of its questions yet.")
|
|
191
|
+
continue
|
|
192
|
+
tops, ranked = best[g]
|
|
193
|
+
top = ranked[0][1]
|
|
194
|
+
names = " or ".join(tops[:2]) + (f" (and {len(tops) - 2} more tied)" if len(tops) > 2 else "")
|
|
195
|
+
line = f"{g}: use {names} ({compare.pct(top)})"
|
|
196
|
+
rest = [x for x in ranked if x[0] not in tops]
|
|
197
|
+
if rest:
|
|
198
|
+
gap = top - rest[0][1]
|
|
199
|
+
line += f"; next {rest[0][0]} ({compare.pct(rest[0][1])}, {100 * gap:.0f} pts behind)"
|
|
200
|
+
if gap < 0.05:
|
|
201
|
+
line += ": not a clear winner"
|
|
202
|
+
if top < 0.5:
|
|
203
|
+
line += ". No model gets half of it right"
|
|
204
|
+
actions.append(line + ".")
|
|
205
|
+
return {"title": title, "lines": lines, "actions": actions}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------- 3 vs base
|
|
209
|
+
def vs_base(rows, models, bases, packs):
|
|
210
|
+
"""bases: {model: its base model's label} (models.toml `base`)."""
|
|
211
|
+
title = "Fine-tunes against their base model"
|
|
212
|
+
pairs = [(m, bases[m]) for m in models if bases.get(m) and any(r["model"] == bases[m] for r in rows)]
|
|
213
|
+
if not pairs:
|
|
214
|
+
missing = [m for m in models if bases.get(m)]
|
|
215
|
+
why = (f"The base models of {', '.join(missing)} have no answers to compare with."
|
|
216
|
+
if missing else 'Add base = "<label of its base model>" to a fine-tune in models.toml to see what it '
|
|
217
|
+
"gained or lost against its base.")
|
|
218
|
+
return {"title": title, "lines": [f"[dim]{_esc(why)}[/dim]"], "actions": []}
|
|
219
|
+
lines, actions = [], []
|
|
220
|
+
label = lambda p: packs[p].label if p in packs else p
|
|
221
|
+
for m, base in pairs:
|
|
222
|
+
two = [r for r in rows if r["model"] in (m, base)]
|
|
223
|
+
res = [x for x in compare.vs_reference(two, base) if x["model"] == m]
|
|
224
|
+
overall = next((x for x in res if x["pack"] == "all"), None)
|
|
225
|
+
if not overall:
|
|
226
|
+
continue
|
|
227
|
+
color = VERDICT_COLOR[overall["verdict"]]
|
|
228
|
+
lines.append(f"[b]{_esc(m)}[/b] vs [b]{_esc(base)}[/b]: [{color}]{_pts(overall['diff'])} pts "
|
|
229
|
+
f"{overall['verdict'].upper()}[/] [dim]({overall['n']} shared questions)[/dim]")
|
|
230
|
+
lines += [" " + line for line in _vs_lines([x for x in res if x["pack"] != "all"], lambda x: label(x["pack"]))]
|
|
231
|
+
cats = categories(two, m, base)
|
|
232
|
+
lost = [c for c in cats if c[1] <= -0.34][:3]
|
|
233
|
+
won = [c for c in sorted(cats, key=lambda c: -c[1]) if c[1] >= 0.34][:3]
|
|
234
|
+
if lost:
|
|
235
|
+
lines.append(" [red]lost on:[/] " + " · ".join(f"{_esc(c)} {_pts(d)} pts ({n} q)" for c, d, n in lost))
|
|
236
|
+
if won:
|
|
237
|
+
lines.append(" [green]gained on:[/] " + " · ".join(f"{_esc(c)} {_pts(d)} pts ({n} q)" for c, d, n in won))
|
|
238
|
+
lines.append("")
|
|
239
|
+
worse = [x for x in res if x["pack"] != "all" and x["verdict"] == "worse"]
|
|
240
|
+
better = [x for x in res if x["pack"] != "all" and x["verdict"] == "better"]
|
|
241
|
+
if worse:
|
|
242
|
+
actions.append(f"{m} is worse than its base on " + ", ".join(f"{label(x['pack'])} ({_pts(x['diff'])})"
|
|
243
|
+
for x in worse)
|
|
244
|
+
+ (f"; it lost most on {', '.join(c for c, _, _ in lost)}: gaps in its fine-tuning data"
|
|
245
|
+
if lost else "") + ".")
|
|
246
|
+
if better:
|
|
247
|
+
actions.append(f"{m} beats its base on " + ", ".join(f"{label(x['pack'])} ({_pts(x['diff'])})" for x in better)
|
|
248
|
+
+ ".")
|
|
249
|
+
if not worse and not better:
|
|
250
|
+
actions.append(f"{m}: no clear difference from {base} on any pack yet"
|
|
251
|
+
+ (f" (losses on {', '.join(c for c, _, _ in lost)} are worth a look)" if lost else "") + ".")
|
|
252
|
+
return {"title": title, "lines": lines[:-1] if lines and not lines[-1] else lines, "actions": actions}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def categories(rows, m, base, min_q=2):
|
|
256
|
+
"""[(category, mean pass-rate difference m - base, questions)] over categories with at least min_q
|
|
257
|
+
questions both answered, worst first. A question's category is its `category`, else its pack."""
|
|
258
|
+
by = collections.defaultdict(lambda: collections.defaultdict(list))
|
|
259
|
+
cat = {}
|
|
260
|
+
for r in rows:
|
|
261
|
+
by[r["model"]][r["test"]].append(r["ok"])
|
|
262
|
+
cat[r["test"]] = (r.get("raw") or {}).get("category") or r["suite"]
|
|
263
|
+
per = collections.defaultdict(list)
|
|
264
|
+
for t in set(by[m]) & set(by[base]):
|
|
265
|
+
per[cat[t]].append(statistics.fmean(by[m][t]) - statistics.fmean(by[base][t]))
|
|
266
|
+
return sorted(((c, statistics.fmean(v), len(v)) for c, v in per.items() if len(v) >= min_q), key=lambda c: c[1])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------- 4 why they fail
|
|
270
|
+
KINDS = (("right", "green", "█"), ("wrong", "red", "█"), ("cut off", "yellow", "█"),
|
|
271
|
+
("no answer / format", "magenta", "█"), ("code crashed or hung", "cyan", "█"), ("critical", "#ff5faf", "▓"))
|
|
272
|
+
FORMAT = ("No 'ANSWER:' line", "Empty answer", "No code", "no tool call")
|
|
273
|
+
CRASHED = ("Timed out after", "Harness crashed", "grader error")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def kind(r):
|
|
277
|
+
if r["ok"]:
|
|
278
|
+
return "right"
|
|
279
|
+
if r.get("severity") == "critical":
|
|
280
|
+
return "critical"
|
|
281
|
+
if r["truncated"] or r["reason"].startswith("TRUNCATED"):
|
|
282
|
+
return "cut off"
|
|
283
|
+
if r["reason"].startswith(FORMAT):
|
|
284
|
+
return "no answer / format"
|
|
285
|
+
if r["reason"].startswith(CRASHED):
|
|
286
|
+
return "code crashed or hung"
|
|
287
|
+
return "wrong"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _split(counts, total, width):
|
|
291
|
+
"""Blocks per kind, largest remainders first, so they add up to width."""
|
|
292
|
+
raw = {k: width * counts.get(k, 0) / total for k, *_ in KINDS}
|
|
293
|
+
out = {k: int(v) for k, v in raw.items()}
|
|
294
|
+
for k in sorted(raw, key=lambda k: -(raw[k] - out[k]))[:width - sum(out.values())]:
|
|
295
|
+
out[k] += 1
|
|
296
|
+
for k in out: # a kind that happened shows at least one block
|
|
297
|
+
if counts.get(k) and not out[k]:
|
|
298
|
+
big = max(out, key=out.get)
|
|
299
|
+
out[big], out[k] = out[big] - 1, 1
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def failure_mix(rows, models, width=36):
|
|
304
|
+
title = "Why models fail"
|
|
305
|
+
counts = {m: collections.Counter(kind(r) for r in rows if r["model"] == m) for m in models}
|
|
306
|
+
counts = {m: c for m, c in counts.items() if c}
|
|
307
|
+
if not counts:
|
|
308
|
+
return {"title": title, "lines": ["[dim]No answers yet.[/dim]"], "actions": []}
|
|
309
|
+
w = max(len(m) for m in counts)
|
|
310
|
+
lines = [" " * (w + 2) + " ".join(f"[{c}]{ch}[/] {k}" for k, c, ch in KINDS)]
|
|
311
|
+
actions = []
|
|
312
|
+
for m in sorted(counts, key=lambda m: -counts[m]["right"] / sum(counts[m].values())):
|
|
313
|
+
c = counts[m]
|
|
314
|
+
total = sum(c.values())
|
|
315
|
+
blocks = _split(c, total, width)
|
|
316
|
+
bar = "".join(f"[{color}]{ch * blocks[k]}[/]" for k, color, ch in KINDS)
|
|
317
|
+
fails = total - c["right"]
|
|
318
|
+
detail = ", ".join(f"{c[k]} {k}" for k, *_ in KINDS[1:] if c[k])
|
|
319
|
+
lines.append(f"{_esc(m):<{w}} {bar} {compare.pct(c['right'] / total)} right [dim]({total} answers"
|
|
320
|
+
+ (f"; {detail}" if detail else "") + ")[/dim]")
|
|
321
|
+
if fails >= 3:
|
|
322
|
+
top, n = max(((k, c[k]) for k, *_ in KINDS[1:-1]), key=lambda x: x[1])
|
|
323
|
+
if n / fails >= 0.4 and top != "wrong":
|
|
324
|
+
actions.append(f"{m}: {n} of its {fails} failures are {top}: " + {
|
|
325
|
+
"cut off": "it thinks too long for max_tokens, rather than reasoning wrong.",
|
|
326
|
+
"no answer / format": "it doesn't follow the answer format (check its chat template and the "
|
|
327
|
+
"pack's instructions).",
|
|
328
|
+
"code crashed or hung": "its code crashes or never finishes."}[top])
|
|
329
|
+
crit = sorted(((m, c["critical"]) for m, c in counts.items() if c["critical"]), key=lambda x: -x[1])
|
|
330
|
+
if crit:
|
|
331
|
+
actions.insert(0, "Critical failures: " + ", ".join(f"{m} ({n})" for m, n in crit[:5])
|
|
332
|
+
+ (f" and {len(crit) - 5} more" if len(crit) > 5 else "")
|
|
333
|
+
+ ": not safe where those matter, whatever the accuracy (Failures lists them).")
|
|
334
|
+
return {"title": title, "lines": lines, "actions": actions}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------- 5 accuracy / cost
|
|
338
|
+
MARKS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def cost_points(rows, models, by="tokens"):
|
|
342
|
+
"""({model: (accuracy, median cost per question)}, questions, shared): over the questions every model
|
|
343
|
+
answered when there are at least 5 of them, else each model over its own."""
|
|
344
|
+
tests = compare.per_question(rows)
|
|
345
|
+
shared = [t for t in tests if all(m in t["cells"] for m in models)]
|
|
346
|
+
use = shared if len(shared) >= 5 else tests
|
|
347
|
+
out = {}
|
|
348
|
+
for m in models:
|
|
349
|
+
cells = [t["cells"][m] for t in use if m in t["cells"]]
|
|
350
|
+
answers = sum(c["n"] for c in cells)
|
|
351
|
+
cost = compare.median(c[by] for c in cells)
|
|
352
|
+
if answers and cost:
|
|
353
|
+
out[m] = (sum(c["passed"] for c in cells) / answers, cost)
|
|
354
|
+
return out, len(use), use is shared
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def front(points):
|
|
358
|
+
"""The models no other model beats on both accuracy and cost."""
|
|
359
|
+
return {m for m, (a, c) in points.items()
|
|
360
|
+
if not any(a2 >= a and c2 <= c and (a2 > a or c2 < c) for m2, (a2, c2) in points.items() if m2 != m)}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def accuracy_cost(rows, models, by="tokens", width=56, height=12, hosted=()):
|
|
364
|
+
"""hosted: models served by an API elsewhere; with local ones too, the local trade-offs get a line."""
|
|
365
|
+
unit = "tokens" if by == "tokens" else "seconds"
|
|
366
|
+
title = f"Accuracy against {unit} per question"
|
|
367
|
+
pts, n, shared = cost_points(rows, models, "tokens" if by == "tokens" else "secs")
|
|
368
|
+
if len(pts) < 2:
|
|
369
|
+
return {"title": title, "lines": ["[dim]Needs two models with answers.[/dim]"], "actions": []}
|
|
370
|
+
best = front(pts)
|
|
371
|
+
order = sorted(pts, key=lambda m: (-pts[m][0], pts[m][1]))
|
|
372
|
+
mark = {m: MARKS[i % len(MARKS)] for i, m in enumerate(order)}
|
|
373
|
+
accs, costs = [a for a, _ in pts.values()], [c for _, c in pts.values()]
|
|
374
|
+
a_lo = math.floor(min(accs) * 10) / 10
|
|
375
|
+
a_lo = min(a_lo, 0.9)
|
|
376
|
+
c_lo, c_hi = math.log(min(costs)), math.log(max(costs))
|
|
377
|
+
grid = [[" "] * width for _ in range(height)]
|
|
378
|
+
for m, (a, c) in pts.items():
|
|
379
|
+
x = 0 if c_hi == c_lo else round((math.log(c) - c_lo) / (c_hi - c_lo) * (width - 1))
|
|
380
|
+
y = round((1 - (a - a_lo) / (1 - a_lo)) * (height - 1)) if a_lo < 1 else 0
|
|
381
|
+
free = next((xx for xx in (x, x + 1, x - 1) if 0 <= xx < width and grid[y][xx] == " "), None)
|
|
382
|
+
if free is None:
|
|
383
|
+
grid[y][x] = "[b]*[/b]"
|
|
384
|
+
else:
|
|
385
|
+
grid[y][free] = f"[bold green]{mark[m]}[/]" if m in best else mark[m]
|
|
386
|
+
fmt = (lambda v: f"{v:,.0f}") if by == "tokens" else (lambda v: f"{v:.0f}s" if v >= 10 else f"{v:.1f}s")
|
|
387
|
+
lines = []
|
|
388
|
+
for i, row in enumerate(grid):
|
|
389
|
+
label = "100%" if i == 0 else f"{100 * a_lo:.0f}%" if i == height - 1 else ""
|
|
390
|
+
lines.append(f"[dim]{label:>5} │[/dim]" + "".join(row))
|
|
391
|
+
lo_s, hi_s = fmt(min(costs)), fmt(max(costs))
|
|
392
|
+
lines.append("[dim] └" + "─" * width + "[/dim]")
|
|
393
|
+
lines.append(f"[dim] {lo_s}{' ' * max(1, width - len(lo_s) - len(hi_s))}{hi_s} median {unit} "
|
|
394
|
+
"per question (log scale) →[/dim]")
|
|
395
|
+
lines.append(f"[dim]Over {n} questions {'every model answered' if shared else 'each model answered (too few in common)'}. "
|
|
396
|
+
"[bold green]Green[/] = no other model is both more accurate and cheaper. * = several models.[/dim]")
|
|
397
|
+
for m in order:
|
|
398
|
+
a, c = pts[m]
|
|
399
|
+
tag = f"[bold green]{mark[m]}[/]" if m in best else mark[m]
|
|
400
|
+
lines.append(f" {tag} {_esc(m)} [dim]{compare.pct(a)} · {fmt(c)}{' · hosted' if m in hosted else ''}[/dim]")
|
|
401
|
+
beaten = collections.defaultdict(list) # front model -> the models it beats on both
|
|
402
|
+
for m in order:
|
|
403
|
+
if m in best:
|
|
404
|
+
continue
|
|
405
|
+
a, c = pts[m]
|
|
406
|
+
better = [(m2, c2) for m2, (a2, c2) in pts.items() if m2 in best and a2 >= a and c2 <= c]
|
|
407
|
+
if better:
|
|
408
|
+
m2, c2 = min(better, key=lambda x: x[1])
|
|
409
|
+
beaten[m2].append(f"{m} ({100 * c2 / c:.0f}% of its {unit}"
|
|
410
|
+
+ (", same accuracy)" if pts[m2][0] == a else ")"))
|
|
411
|
+
actions = ["Best trade-offs: " + ", ".join(m for m in order if m in best) + "."]
|
|
412
|
+
local = {m: p for m, p in pts.items() if m not in hosted}
|
|
413
|
+
if hosted and len(local) >= 2 and len(local) < len(pts):
|
|
414
|
+
lf = front(local)
|
|
415
|
+
actions.append("Among local models: " + ", ".join(m for m in order if m in lf) + ".")
|
|
416
|
+
for m2, items in beaten.items():
|
|
417
|
+
actions.append(f"{m2} is at least as accurate with fewer {unit} than " + ", ".join(items[:4])
|
|
418
|
+
+ (f" and {len(items) - 4} more" if len(items) > 4 else "") + ".")
|
|
419
|
+
return {"title": title, "lines": lines, "actions": actions}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------- 6 question health
|
|
423
|
+
def question_health(rows, models, min_models=3, width=40):
|
|
424
|
+
title = "Question health"
|
|
425
|
+
need = min(min_models, len(models))
|
|
426
|
+
tests = [t for t in compare.per_question(rows) if len(t["cells"]) >= max(2, need)]
|
|
427
|
+
if not tests:
|
|
428
|
+
return {"title": title, "lines": [f"[dim]Needs questions answered by at least {max(2, need)} models.[/dim]"],
|
|
429
|
+
"actions": []}
|
|
430
|
+
rate = lambda t: statistics.fmean(c["passed"] / c["n"] for c in t["cells"].values())
|
|
431
|
+
none = [t for t in tests if all(c["passed"] == 0 for c in t["cells"].values())]
|
|
432
|
+
every = [t for t in tests if all(c["passed"] == c["n"] for c in t["cells"].values())]
|
|
433
|
+
split = sorted((t for t in tests if t not in none and t not in every),
|
|
434
|
+
key=lambda t: (abs(rate(t) - 0.5), -len(t["cells"])))
|
|
435
|
+
n = len(tests)
|
|
436
|
+
blocks = _split({"wrong": len(none), "right": len(every), "cut off": len(split)}, n, width)
|
|
437
|
+
bar = f"[red]{'█' * blocks['wrong']}[/][yellow]{'█' * blocks['cut off']}[/][#808080]{'█' * blocks['right']}[/]"
|
|
438
|
+
lines = [f"{bar} [dim]{n} questions answered by {max(2, need)}+ models[/dim]",
|
|
439
|
+
f"[yellow]█[/] {len(split)} split models [red]█[/] {len(none)} every model fails "
|
|
440
|
+
f"[#808080]█[/] {len(every)} every model passes"]
|
|
441
|
+
desc = lambda t: (t["cells"][next(iter(t["cells"]))]["rows"][0].get("raw") or {}).get("description") or t["name"]
|
|
442
|
+
for name, items, color in (("Split models most", split, "yellow"), ("Every model fails", none, "red"),
|
|
443
|
+
("Every model passes", every, "#808080")):
|
|
444
|
+
if items:
|
|
445
|
+
lines.append(f"[{color}]{name}:[/]")
|
|
446
|
+
for t in items[:5]:
|
|
447
|
+
lines.append(f" [dim]{_esc(t['suite'])}[/dim] {_esc(desc(t)[:70])}"
|
|
448
|
+
+ (f" [dim]({100 * rate(t):.0f}% of answers right)[/dim]" if items is split else ""))
|
|
449
|
+
if len(items) > 5:
|
|
450
|
+
lines.append(f" [dim]… and {len(items) - 5} more[/dim]")
|
|
451
|
+
actions = []
|
|
452
|
+
if none:
|
|
453
|
+
actions.append(f"{len(none)} question{'s' * (len(none) > 1)} no model passes: check their expected answers "
|
|
454
|
+
"(or keep them as stretch goals).")
|
|
455
|
+
if every:
|
|
456
|
+
secs = statistics.median(c["secs"] for t in every for c in t["cells"].values() if c["secs"]) \
|
|
457
|
+
if any(c["secs"] for t in every for c in t["cells"].values()) else None
|
|
458
|
+
saved = f", saving ~{_dur(secs * len(every))} per model" if secs else ""
|
|
459
|
+
actions.append(f"{len(every)} question{'s' * (len(every) > 1)} every model passes: they don't separate models; "
|
|
460
|
+
f"move them to a regression pack{saved}.")
|
|
461
|
+
if split:
|
|
462
|
+
actions.append(f"{len(split)} question{'s' * (len(split) > 1)} split models: these decide rankings; "
|
|
463
|
+
"write more like the top ones.")
|
|
464
|
+
return {"title": title, "lines": lines, "actions": actions}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _dur(secs):
|
|
468
|
+
secs = int(secs)
|
|
469
|
+
return f"{secs // 3600}h{secs % 3600 // 60:02d}m" if secs >= 3600 else f"{secs // 60}m" if secs >= 60 else f"{secs}s"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---------------------------------------------------------------- 7 next runs
|
|
473
|
+
def next_runs(engine, models, ranking, limit=4, skip=()):
|
|
474
|
+
"""ranking: models best first (by the reference comparison, else accuracy). The Certify runs that
|
|
475
|
+
would settle the open verdicts of the strongest `limit` models, with their time."""
|
|
476
|
+
from . import verdict
|
|
477
|
+
title = "Next runs"
|
|
478
|
+
table = verdict.readiness(engine, models)
|
|
479
|
+
times = engine.answer_times()
|
|
480
|
+
med = {l: {p: statistics.median(ts) for p, ts in pp.items() if ts} for l, pp in times.items()}
|
|
481
|
+
lines, actions, total = [], [], 0
|
|
482
|
+
shown = 0
|
|
483
|
+
for m in ranking:
|
|
484
|
+
if m not in table or m in skip or shown >= limit:
|
|
485
|
+
continue
|
|
486
|
+
todo = []
|
|
487
|
+
for g, (v, packs) in table[m].items():
|
|
488
|
+
for name, pv in packs.items():
|
|
489
|
+
ev = pv.evidence
|
|
490
|
+
if pv.status != "INCONCLUSIVE" or not ev or ev.get("certified"):
|
|
491
|
+
continue
|
|
492
|
+
left = max(0, ev["tests_total"] * ev["want_repeat"] - ev["trials"])
|
|
493
|
+
spa = med.get(m, {}).get(name) or statistics.median(
|
|
494
|
+
[x[name] for x in med.values() if name in x] or [60.0])
|
|
495
|
+
todo.append((name, left, left * spa))
|
|
496
|
+
if not todo:
|
|
497
|
+
continue
|
|
498
|
+
shown += 1
|
|
499
|
+
answers, secs = sum(x[1] for x in todo), sum(x[2] for x in todo)
|
|
500
|
+
label = ", ".join(engine.packs[n].label if n in engine.packs else n for n, *_ in todo)
|
|
501
|
+
lines.append(f"[b]{shown}. Certify {_esc(m)}[/b] on {_esc(label)}: ~{answers} answers, ~{_dur(secs)}")
|
|
502
|
+
lines.append(f" [dim]tuieval run --tier certify --only {_esc(m)} --packs {','.join(n for n, *_ in todo)}[/dim]")
|
|
503
|
+
total += secs
|
|
504
|
+
if shown:
|
|
505
|
+
actions.append(f"Certifying {'these ' + str(shown) + ' contenders' if shown > 1 else 'it'} takes ~{_dur(total)} "
|
|
506
|
+
"in all; the rest are behind them.")
|
|
507
|
+
if not lines:
|
|
508
|
+
lines = ["[dim]Nothing open among the strongest models: their verdicts are settled or not run yet.[/dim]"]
|
|
509
|
+
return {"title": title, "lines": lines, "actions": actions}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------- all of them
|
|
513
|
+
def ranking(rows, models, ref):
|
|
514
|
+
"""Models best first: by the difference to the reference (the reference first), else by accuracy."""
|
|
515
|
+
diff = {x["model"]: x["diff"] for x in compare.vs_reference(rows, ref) if x["pack"] == "all"} if ref else {}
|
|
516
|
+
acc = {m: statistics.fmean(r["ok"] for r in rows if r["model"] == m) for m in models
|
|
517
|
+
if any(r["model"] == m for r in rows)}
|
|
518
|
+
key = (lambda m: -(diff.get(m, 0) if m != ref else 0)) if diff else (lambda m: -acc.get(m, 0))
|
|
519
|
+
return sorted(acc, key=lambda m: (key(m), m))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def panels(rows, models, packs, ref=None, bases=None, by="tokens", engine=None):
|
|
523
|
+
"""All seven (the last needs the engine: verdicts and answer times). Models are shown best first."""
|
|
524
|
+
order = ranking(rows, models, ref)
|
|
525
|
+
order += [m for m in models if m not in order]
|
|
526
|
+
hosted = {m["label"] for m in engine.cfg["models"] if engine.hosted(m)} if engine is not None else set()
|
|
527
|
+
out = [vs_reference(rows, order, ref), use_cases(rows, order, packs), vs_base(rows, order, bases or {}, packs),
|
|
528
|
+
failure_mix(rows, order), accuracy_cost(rows, order, by, hosted=hosted), question_health(rows, order)]
|
|
529
|
+
if engine is not None:
|
|
530
|
+
out.append(next_runs(engine, order, order, skip={ref}))
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def render(panel, number=None):
|
|
535
|
+
"""A panel as Rich markup: title, chart, then its action lines."""
|
|
536
|
+
head = f"[b reverse] {f'{number} ' if number else ''}{panel['title']} [/]"
|
|
537
|
+
acts = [f"[bold cyan]→[/] {_esc(a)}" for a in panel["actions"][:MAX_ACTIONS]]
|
|
538
|
+
return "\n".join([head, ""] + panel["lines"] + ([""] + acts if acts else []))
|
|
@@ -427,6 +427,42 @@ def cmd_pta(argv):
|
|
|
427
427
|
out.print(line, markup=False)
|
|
428
428
|
|
|
429
429
|
|
|
430
|
+
def cmd_insights(argv):
|
|
431
|
+
p = argparse.ArgumentParser(prog="tuieval insights",
|
|
432
|
+
description="Charts with what to do next: every model against a reference, the best "
|
|
433
|
+
"model per use case, fine-tunes against their base, why models fail, "
|
|
434
|
+
"accuracy against cost, question health and the runs that would settle "
|
|
435
|
+
"open verdicts.")
|
|
436
|
+
p.add_argument("--only", help="comma-separated model labels (default: every model with enough answers)")
|
|
437
|
+
p.add_argument("--reference", help="the model to compare against (default: [defaults] reference, else the "
|
|
438
|
+
"most accurate)")
|
|
439
|
+
p.add_argument("--packs", help="comma-separated packs (default: all)")
|
|
440
|
+
p.add_argument("--cost", choices=("tokens", "time"), default="tokens", help="the cost axis of the accuracy chart")
|
|
441
|
+
p.add_argument("--models")
|
|
442
|
+
p.add_argument("--results-dir")
|
|
443
|
+
a = p.parse_args(argv)
|
|
444
|
+
from rich.console import Console
|
|
445
|
+
from . import compare, insights
|
|
446
|
+
e = engine.Engine(models_path=a.models, results_dir=a.results_dir)
|
|
447
|
+
paths = [x for x in compare.default_paths(e.results_dir) if not e.superseded(x)]
|
|
448
|
+
rows, _ = compare.load(paths) if paths else ([], [])
|
|
449
|
+
if a.packs:
|
|
450
|
+
rows = [r for r in rows if r["suite"] in {x.strip() for x in a.packs.split(",")}]
|
|
451
|
+
wanted = a.reference or e.cfg["defaults"].get("reference")
|
|
452
|
+
picked = {x.strip() for x in a.only.split(",")} if a.only else None
|
|
453
|
+
rows, models = insights.prepare(rows, picked, wanted)
|
|
454
|
+
if not models:
|
|
455
|
+
sys.exit("no finished results" + (" for those models or packs" if a.only or a.packs else ""))
|
|
456
|
+
if a.reference and a.reference not in models:
|
|
457
|
+
sys.exit(f"{a.reference} has no finished results")
|
|
458
|
+
ref = insights.default_reference(rows, models, wanted)
|
|
459
|
+
bases = {m["label"]: m["base"] for m in e.cfg["models"] if m.get("base")}
|
|
460
|
+
out = Console(highlight=False)
|
|
461
|
+
for i, panel in enumerate(insights.panels(rows, models, e.packs, ref, bases, a.cost, engine=e), 1):
|
|
462
|
+
out.print(insights.render(panel, i), soft_wrap=True)
|
|
463
|
+
out.print()
|
|
464
|
+
|
|
465
|
+
|
|
430
466
|
def cmd_report(argv):
|
|
431
467
|
p = argparse.ArgumentParser(prog="tuieval report", description="Write the readiness report as markdown.")
|
|
432
468
|
p.add_argument("-o", "--output", help="default: reports/readiness-<date>.md")
|
|
@@ -708,7 +744,7 @@ def cmd_export(argv):
|
|
|
708
744
|
COMMANDS = {"add": cmd_add, "list": cmd_list, "scan": cmd_scan, "regrade": cmd_regrade,
|
|
709
745
|
"verdict": cmd_verdict, "report": cmd_report, "items": cmd_items, "selftest": cmd_selftest,
|
|
710
746
|
"capture": cmd_capture, "history": cmd_history, "machines": cmd_machines, "tune": cmd_tune,
|
|
711
|
-
"export": cmd_export, "remove": cmd_remove, "pta": cmd_pta}
|
|
747
|
+
"export": cmd_export, "remove": cmd_remove, "pta": cmd_pta, "insights": cmd_insights}
|
|
712
748
|
|
|
713
749
|
|
|
714
750
|
def parse_tests(text, pack_names, known):
|
|
@@ -2690,6 +2690,14 @@ class ResultsScreen(Screen):
|
|
|
2690
2690
|
yield Static(id="pta-bars")
|
|
2691
2691
|
yield DataTable(id="pta", zebra_stripes=True, cursor_type="none")
|
|
2692
2692
|
yield Static(id="pta-left-out")
|
|
2693
|
+
with TabPane("Insights", id="tab-insights"):
|
|
2694
|
+
with Horizontal(id="ins-controls"):
|
|
2695
|
+
yield Select([], id="ins-ref", prompt="Reference model", allow_blank=True)
|
|
2696
|
+
yield Select([("Cost: tokens", "tokens"), ("Cost: time", "time")], id="ins-cost",
|
|
2697
|
+
allow_blank=False, value="tokens")
|
|
2698
|
+
yield Button("Models: all", id="ins-models")
|
|
2699
|
+
with VerticalScroll(id="ins-body"):
|
|
2700
|
+
yield Static(id="ins-panels")
|
|
2693
2701
|
with TabPane("Production readiness"):
|
|
2694
2702
|
yield Static(id="readiness-short")
|
|
2695
2703
|
yield Static(self.READINESS_HELP, id="readiness-help")
|
|
@@ -2799,6 +2807,9 @@ class ResultsScreen(Screen):
|
|
|
2799
2807
|
self.fill_per_question()
|
|
2800
2808
|
self.fill_pta()
|
|
2801
2809
|
self.fill_readiness(results_dir)
|
|
2810
|
+
self.insights_stale = True
|
|
2811
|
+
if self.query_one(TabbedContent).active == "tab-insights":
|
|
2812
|
+
self.fill_insights()
|
|
2802
2813
|
self.sort_tables()
|
|
2803
2814
|
|
|
2804
2815
|
@on(DataTable.RowSelected, "#failures")
|
|
@@ -2863,6 +2874,7 @@ class ResultsScreen(Screen):
|
|
|
2863
2874
|
|
|
2864
2875
|
@on(Button.Pressed, "#pq-models")
|
|
2865
2876
|
@on(Button.Pressed, "#pta-models")
|
|
2877
|
+
@on(Button.Pressed, "#ins-models")
|
|
2866
2878
|
def pick_models(self):
|
|
2867
2879
|
counts = {}
|
|
2868
2880
|
for t in getattr(self, "pq_all_tests", []):
|
|
@@ -2881,8 +2893,55 @@ class ResultsScreen(Screen):
|
|
|
2881
2893
|
# after the picker has closed: a table rebuilt under a modal keeps its header-only widths
|
|
2882
2894
|
self.call_after_refresh(self.fill_per_question)
|
|
2883
2895
|
self.call_after_refresh(self.fill_pta)
|
|
2896
|
+
self.insights_stale = True
|
|
2897
|
+
if self.query_one(TabbedContent).active == "tab-insights":
|
|
2898
|
+
self.call_after_refresh(self.fill_insights)
|
|
2884
2899
|
self.app.push_screen(ModelPickScreen(models, counts, self.app.pq_models), picked)
|
|
2885
2900
|
|
|
2901
|
+
# -- insights: charts with what to do next (insights.py); built when the tab is first shown
|
|
2902
|
+
@on(TabbedContent.TabActivated)
|
|
2903
|
+
def tab_activated(self, event):
|
|
2904
|
+
if event.pane.id == "tab-insights" and getattr(self, "insights_stale", True):
|
|
2905
|
+
self.fill_insights()
|
|
2906
|
+
|
|
2907
|
+
@on(Select.Changed, "#ins-ref")
|
|
2908
|
+
@on(Select.Changed, "#ins-cost")
|
|
2909
|
+
def insights_changed(self, event):
|
|
2910
|
+
if event.select.id == "ins-ref" and event.value != Select.BLANK:
|
|
2911
|
+
self.app.insights_ref = event.value # kept for this session
|
|
2912
|
+
if getattr(self, "rows", None) is not None and not getattr(self, "filling_insights", False):
|
|
2913
|
+
self.fill_insights()
|
|
2914
|
+
|
|
2915
|
+
def fill_insights(self):
|
|
2916
|
+
from . import insights
|
|
2917
|
+
e = self.app.engine
|
|
2918
|
+
self.insights_stale = False
|
|
2919
|
+
rows = [r for r in self.rows if r.get("path") not in self.superseded]
|
|
2920
|
+
body = self.query_one("#ins-panels", Static)
|
|
2921
|
+
if not rows:
|
|
2922
|
+
body.update("No finished results yet.")
|
|
2923
|
+
return
|
|
2924
|
+
sel = self.query_one("#ins-ref", Select)
|
|
2925
|
+
every = sorted({r["model"] for r in rows if not r.get("error")})
|
|
2926
|
+
wanted = getattr(self.app, "insights_ref", None) or e.cfg["defaults"].get("reference")
|
|
2927
|
+
rows, models = insights.prepare(rows, self.app.pq_models, wanted if wanted in every else None)
|
|
2928
|
+
ref = insights.default_reference(rows, models, wanted)
|
|
2929
|
+
self.filling_insights = True
|
|
2930
|
+
try:
|
|
2931
|
+
with sel.prevent(Select.Changed):
|
|
2932
|
+
sel.set_options([(m, m) for m in every])
|
|
2933
|
+
if ref:
|
|
2934
|
+
sel.value = ref
|
|
2935
|
+
finally:
|
|
2936
|
+
self.filling_insights = False
|
|
2937
|
+
chosen = {m for m in self.app.pq_models if m in every}
|
|
2938
|
+
self.query_one("#ins-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
|
|
2939
|
+
main = os.path.abspath(self.results_dir or e.results_dir) == os.path.abspath(e.results_dir)
|
|
2940
|
+
bases = {m["label"]: m["base"] for m in e.cfg["models"] if m.get("base")}
|
|
2941
|
+
by = self.query_one("#ins-cost", Select).value or "tokens"
|
|
2942
|
+
out = insights.panels(rows, models, e.packs, ref, bases, by, engine=e if main else None)
|
|
2943
|
+
body.update("\n\n".join(insights.render(p, i) for i, p in enumerate(out, 1)))
|
|
2944
|
+
|
|
2886
2945
|
@on(Select.Changed, "#pta-pack")
|
|
2887
2946
|
def pta_changed(self):
|
|
2888
2947
|
if getattr(self, "rows", None) is not None:
|
|
@@ -3206,6 +3265,10 @@ class EvalsApp(App):
|
|
|
3206
3265
|
#toggle-smoke { width: auto; min-width: 32; }
|
|
3207
3266
|
#pq-controls { height: 3; }
|
|
3208
3267
|
#pta-controls { height: 3; }
|
|
3268
|
+
#ins-controls { height: 3; }
|
|
3269
|
+
#ins-controls Select { width: 40; margin-right: 1; }
|
|
3270
|
+
#ins-controls #ins-cost { width: 20; }
|
|
3271
|
+
#ins-body { height: 1fr; }
|
|
3209
3272
|
#pta-controls Select { width: 28; margin-right: 1; }
|
|
3210
3273
|
#pta-bars { height: auto; padding: 1 0 0 0; }
|
|
3211
3274
|
#pta { width: 1fr; height: auto; margin-top: 1; }
|
|
@@ -1656,7 +1656,7 @@ class FirstRun(unittest.TestCase):
|
|
|
1656
1656
|
""")
|
|
1657
1657
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
|
1658
1658
|
env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
|
|
1659
|
-
self.assertEqual(out, "ResultsScreen tab-pta | PTA index | Production readiness | Per question | Failures")
|
|
1659
|
+
self.assertEqual(out, "ResultsScreen tab-pta | PTA index | Insights | Production readiness | Per question | Failures")
|
|
1660
1660
|
|
|
1661
1661
|
def test_stream_follows_only_at_the_bottom(self):
|
|
1662
1662
|
code = textwrap.dedent("""
|
|
@@ -2445,5 +2445,127 @@ class StallGuard(unittest.TestCase):
|
|
|
2445
2445
|
self.assertEqual(fake.live_note("b"), "")
|
|
2446
2446
|
|
|
2447
2447
|
|
|
2448
|
+
class Insights(unittest.TestCase):
|
|
2449
|
+
"""Results → Insights: each chart's numbers and the action lines under it."""
|
|
2450
|
+
|
|
2451
|
+
@staticmethod
|
|
2452
|
+
def rows():
|
|
2453
|
+
from types import SimpleNamespace
|
|
2454
|
+
out = []
|
|
2455
|
+
|
|
2456
|
+
def add(model, suite, i, ok, tokens=100, secs=10, **kw):
|
|
2457
|
+
reason = kw.pop("reason", "ok" if ok else "expected 1, got 2")
|
|
2458
|
+
out.append({"model": model, "suite": suite, "test": f"{suite}: q{i}", "name": f"{suite}: q{i}", "ok": ok,
|
|
2459
|
+
"reason": reason, "error": False, "truncated": kw.pop("truncated", False), "tokens": tokens,
|
|
2460
|
+
"latency": secs * 1000, "gen_tps": None, "repeat": 0, "severity": kw.pop("severity", None),
|
|
2461
|
+
"raw": {"category": kw.pop("category", "cat-" + str(i % 2)), "description": f"question {i}"}})
|
|
2462
|
+
for i in range(20):
|
|
2463
|
+
suite = "code" if i < 10 else "data"
|
|
2464
|
+
add("ref", suite, i, True, tokens=400)
|
|
2465
|
+
add("tuned", suite, i, i % 2 == 1 or suite == "code", tokens=100, category="odd" if i % 2 else "even")
|
|
2466
|
+
add("base", suite, i, i not in (0, 1), tokens=120, category="odd" if i % 2 else "even")
|
|
2467
|
+
add("long", suite, i, i < 5, tokens=900, truncated=i >= 5,
|
|
2468
|
+
reason="ok" if i < 5 else "TRUNCATED: hit max_tokens before finishing")
|
|
2469
|
+
packs = {"code": SimpleNamespace(group="Coding", label="Code", tests=[{}] * 10),
|
|
2470
|
+
"data": SimpleNamespace(group="Data", label="Data pack", tests=[{}] * 10)}
|
|
2471
|
+
return out, packs
|
|
2472
|
+
|
|
2473
|
+
def test_panels(self):
|
|
2474
|
+
from rich.text import Text
|
|
2475
|
+
from tuieval import insights
|
|
2476
|
+
rows, packs = self.rows()
|
|
2477
|
+
rows, models = insights.prepare(rows)
|
|
2478
|
+
self.assertEqual(insights.default_reference(rows, models), "ref")
|
|
2479
|
+
self.assertEqual(insights.default_reference(rows, models, "base"), "base")
|
|
2480
|
+
plain = lambda p: Text.from_markup(insights.render(p)).plain
|
|
2481
|
+
|
|
2482
|
+
p = insights.vs_reference(rows, models, "ref")
|
|
2483
|
+
self.assertIn("Behind ref for certain: tuned (-25.0), long (-75.0)", " ".join(p["actions"]))
|
|
2484
|
+
self.assertIn("WORSE", plain(p))
|
|
2485
|
+
|
|
2486
|
+
p = insights.use_cases(rows, models, packs)
|
|
2487
|
+
self.assertIn("Coding: use ref or tuned (100%); next base (80.0%, 20 pts behind).", p["actions"])
|
|
2488
|
+
self.assertIn("★", plain(p))
|
|
2489
|
+
|
|
2490
|
+
p = insights.vs_base(rows, models, {}, packs)
|
|
2491
|
+
self.assertIn('base = "<label of its base model>"', plain(p))
|
|
2492
|
+
p = insights.vs_base(rows, models, {"tuned": "base"}, packs)
|
|
2493
|
+
text = plain(p)
|
|
2494
|
+
self.assertIn("tuned vs base", text)
|
|
2495
|
+
self.assertIn("lost on: even", text) # failed every even data question the base passed
|
|
2496
|
+
self.assertTrue(any(a.startswith("tuned is worse than its base on Data pack") for a in p["actions"]),
|
|
2497
|
+
p["actions"])
|
|
2498
|
+
|
|
2499
|
+
p = insights.failure_mix(rows, models)
|
|
2500
|
+
self.assertIn("long: 15 of its 15 failures are cut off", " ".join(p["actions"]))
|
|
2501
|
+
self.assertIn("25.0% right (20 answers; 15 cut off)", plain(p))
|
|
2502
|
+
self.assertEqual(insights.kind({"ok": False, "severity": "critical", "truncated": True, "reason": "x"}),
|
|
2503
|
+
"critical")
|
|
2504
|
+
self.assertEqual(insights.kind({"ok": False, "truncated": False, "reason": "No 'ANSWER:' line found"}),
|
|
2505
|
+
"no answer / format")
|
|
2506
|
+
|
|
2507
|
+
p = insights.accuracy_cost(rows, models, hosted={"ref"})
|
|
2508
|
+
self.assertEqual(insights.front(insights.cost_points(rows, models)[0]), {"ref", "base", "tuned"})
|
|
2509
|
+
self.assertIn("Best trade-offs: ref, base, tuned.", p["actions"])
|
|
2510
|
+
self.assertIn("Among local models: base, tuned.", p["actions"])
|
|
2511
|
+
self.assertIn("tuned is at least as accurate with fewer tokens than long (11% of its tokens).", p["actions"])
|
|
2512
|
+
|
|
2513
|
+
p = insights.question_health(rows, models)
|
|
2514
|
+
self.assertIn("0 every model fails", plain(p))
|
|
2515
|
+
self.assertTrue(any(a.startswith("3 questions every model passes") and "saving ~30s per model" in a for a in p["actions"]), p["actions"])
|
|
2516
|
+
|
|
2517
|
+
def test_cli_and_tui(self):
|
|
2518
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2519
|
+
ws = os.path.join(tmp, "ws")
|
|
2520
|
+
tuieval(tmp, "init", ws)
|
|
2521
|
+
d = os.path.join(ws, "packs", "apps")
|
|
2522
|
+
os.makedirs(d)
|
|
2523
|
+
write(os.path.join(d, "pack.toml"), 'label = "apps"\n[certify]\nrepeat = 1\n[gate]\nmin_accuracy = 0.1\n')
|
|
2524
|
+
write(os.path.join(d, "tests.yaml"), "".join(
|
|
2525
|
+
f"- {{id: {t}, input: question {t}, expected: 1, reference: 'ANSWER: 1', wrong: ['ANSWER: 2'], "
|
|
2526
|
+
"difficulty: easy}\n" for t in "abcd"))
|
|
2527
|
+
right = Mock(os.path.join(ws, "packs"), "oracle")
|
|
2528
|
+
wrong = Mock(os.path.join(ws, "packs"), "wrong")
|
|
2529
|
+
try:
|
|
2530
|
+
with open(os.path.join(ws, "models.toml"), "a") as f:
|
|
2531
|
+
f.write(f'\n[servers.here]\nurl = "http://127.0.0.1:{right.port}"\n'
|
|
2532
|
+
f'\n[servers.there]\nurl = "http://127.0.0.1:{wrong.port}"\n')
|
|
2533
|
+
tuieval(ws, "add", "mock", "--server", "here", "--label", "good")
|
|
2534
|
+
tuieval(ws, "add", "mock", "--server", "there", "--label", "bad")
|
|
2535
|
+
tuieval(ws, "run", "--tier", "certify")
|
|
2536
|
+
finally:
|
|
2537
|
+
right.stop()
|
|
2538
|
+
wrong.stop()
|
|
2539
|
+
out = tuieval(ws, "insights").stdout
|
|
2540
|
+
for title in ("1 Who's worth keeping", "2 Best model per use case", "4 Why models fail", "7 Next runs"):
|
|
2541
|
+
self.assertIn(title, out)
|
|
2542
|
+
self.assertIn("Behind good for certain: bad (-100.0)", out)
|
|
2543
|
+
self.assertIn("Reference: bad", tuieval(ws, "insights", "--reference", "bad").stdout)
|
|
2544
|
+
code = textwrap.dedent("""
|
|
2545
|
+
import asyncio
|
|
2546
|
+
from tuieval.tui import EvalsApp, ResultsScreen
|
|
2547
|
+
from textual.widgets import Select, Static, TabbedContent
|
|
2548
|
+
async def go():
|
|
2549
|
+
app = EvalsApp({})
|
|
2550
|
+
async with app.run_test(size=(180, 60)) as pilot:
|
|
2551
|
+
await pilot.pause()
|
|
2552
|
+
app.push_screen(ResultsScreen())
|
|
2553
|
+
await pilot.pause(0.5)
|
|
2554
|
+
app.screen.query_one(TabbedContent).active = "tab-insights"
|
|
2555
|
+
await pilot.pause(0.5)
|
|
2556
|
+
text = str(app.screen.query_one("#ins-panels", Static).render())
|
|
2557
|
+
print("good" == app.screen.query_one("#ins-ref", Select).value, "7 Next runs" in text,
|
|
2558
|
+
"Behind good for certain" in text)
|
|
2559
|
+
app.screen.query_one("#ins-ref", Select).value = "bad"
|
|
2560
|
+
await pilot.pause(0.5)
|
|
2561
|
+
text = str(app.screen.query_one("#ins-panels", Static).render())
|
|
2562
|
+
print("Reference: bad" in text, app.insights_ref)
|
|
2563
|
+
asyncio.run(go())
|
|
2564
|
+
""")
|
|
2565
|
+
p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
|
|
2566
|
+
env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""), timeout=90)
|
|
2567
|
+
self.assertEqual(p.stdout.split(), ["True", "True", "True", "True", "bad"], p.stderr[-2000:])
|
|
2568
|
+
|
|
2569
|
+
|
|
2448
2570
|
if __name__ == "__main__":
|
|
2449
2571
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev92 → tuieval-0.2.0.dev94}/src/tuieval/templates/packs/tool_call/tools.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|