tuieval 0.2.0.dev127__tar.gz → 0.2.0.dev131__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/models.md +2 -1
  4. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/compare.py +28 -3
  6. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/insights.py +12 -6
  7. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/pta.py +23 -9
  8. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/run_evals.py +3 -1
  9. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/tui.py +92 -17
  10. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/tests/test_tuieval.py +155 -2
  11. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/.gitignore +0 -0
  12. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/LICENSE +0 -0
  13. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/README.md +0 -0
  14. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/RELEASING.md +0 -0
  15. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/agent-packs.md +0 -0
  16. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/README.md +0 -0
  17. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/favicon.ico +0 -0
  18. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/readme-header.png +0 -0
  19. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/social-preview.png +0 -0
  20. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-1024.png +0 -0
  21. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-128.png +0 -0
  22. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-16.png +0 -0
  23. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-16.svg +0 -0
  24. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-256.png +0 -0
  25. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-32.png +0 -0
  26. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-48.png +0 -0
  27. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-512.png +0 -0
  28. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-64.png +0 -0
  29. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  30. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/brand/tuieval-icon.svg +0 -0
  31. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/tui-answer-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/tui-run-reasoning.png +0 -0
  33. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/images/tui-setup.png +0 -0
  34. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/docs/writing-packs.md +0 -0
  35. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/pyproject.toml +0 -0
  36. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/__init__.py +0 -0
  37. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/__main__.py +0 -0
  38. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/agents.py +0 -0
  39. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/builtin.toml +0 -0
  40. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/cli.py +0 -0
  41. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/client.py +0 -0
  42. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/dates.py +0 -0
  43. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/doctor.py +0 -0
  44. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/engine.py +0 -0
  45. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/export.py +0 -0
  46. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/graders/__init__.py +0 -0
  47. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/graders/answer.py +0 -0
  48. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/graders/code.py +0 -0
  49. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/graders/rag.py +0 -0
  50. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/graders/reply.py +0 -0
  51. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/graders/tool_call.py +0 -0
  52. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/logs.py +0 -0
  53. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/machines.py +0 -0
  54. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/onboard.py +0 -0
  55. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/packs.py +0 -0
  56. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/profiles.py +0 -0
  57. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/remove.py +0 -0
  58. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/scaffold.py +0 -0
  59. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/selftest.py +0 -0
  60. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev127 → tuieval-0.2.0.dev131}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Partial credit: the PTA index shows each model's score (e.g. 5 of 6 checks passed is 83%) beside accuracy, and can be ordered by it (By score, `tuieval pta --sort score`). Why models fail splits failures into near miss, partly right and wrong; Failures shows each one's kind and checks and filters by kind; Per question cells show `✗ 5/6`. Verdicts are unchanged.
6
+ - Results: picking a pack (PTA index or Per question) compares the models that answered all of it; the Models button can change that, and All packs goes back to the earlier pick.
5
7
  - Fix: a request that runs out of time says `time limit: timed out after N s`, not `connection error`, and `tuieval compare` counts it against the model like the run does (it was left out as an error).
6
8
  - Agent packs (`kind = "agent"`): each test is a small project with a task, worked on by a command-line agent you define in `[agents]`, using the model tuieval serves, and graded by the task's visible and hidden tests. Results show the agent's stats, diff and transcript; `tuieval compare` adds an Agent tasks table; `tuieval selftest` checks each task's reference and wrong fixes. See docs/agent-packs.md.
7
9
  - Answers that used more tokens than the `max_tokens` sent (a provider that didn't stop them) are marked over budget: `(n over budget)` in the PTA index, an **over budget** column, `over` in Per question and a note in the answer view. `[defaults] over_budget = "fail"` fails them instead. Works on stored results.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev127
3
+ Version: 0.2.0.dev131
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
@@ -105,6 +105,7 @@ The **PTA index** puts parsimony (tokens), time and accuracy side by side, as a
105
105
  - **P parsimony (tokens)**: the tokens used, reasoning and answer, on every question compared (each question at its median over repeats). Beside the bar: how many times the leanest model's, e.g. `2.4×` (`1.0×` is the leanest, ★ in the table's **tokens/answer**). Unlike time 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.
106
106
  - **T time**: the total time to answer those questions, as how many times the fastest model's, e.g. `28×` (★ in **time/answer**).
107
107
  - **A accuracy**: the share of answers to those questions that passed. Answers cut off at `max_tokens` count as wrong, but show in orange after the green, with `(n% truncated)` beside the number and a **truncated** column in the table: those failures are a model out of tokens (overthinking), not a wrong answer yet. A pack's own `max_tokens` gives it more room.
108
+ - **Score**: partial credit, averaged over the same answers. Graders that can tell how close an answer came give it: the code grader the share of checks passed (5 of 6 is 83%, where accuracy counts it wrong), agent packs the share of hidden tests, the tool-call and RAG graders part marks. It shows beside the accuracy when the two differ, and in a **score** column. Order the models by it with the **By score** dropdown in the tab or `tuieval pta --sort score`; verdicts still use accuracy only.
108
109
  - **Over budget**: some providers don't stop a model at `max_tokens` (thinking goes on past it), so the answer isn't cut off but the model had more room than the others. Answers that used more tokens than were sent as `max_tokens` show as `(n over budget)` beside the accuracy, an **over budget** column, `over` in Results → Per question and a note in the answer view. They're only flagged; `over_budget = "fail"` in `[defaults]` counts them as failed instead (stored results aren't changed, so switching back restores them).
109
110
 
110
111
  The P and T bars are as long as the tokens and time used: the model that used the most fills its bar, the others in proportion (at least one block), so **shorter is better**. The A bar is the share of answers right, so **longer is better**; it's full only when every answer passed. Each heading says which way is better.
@@ -118,7 +119,7 @@ Results → **Insights** (also `tuieval insights`) turns the results into charts
118
119
  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.
119
120
  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.
120
121
  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.
121
- 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.
122
+ 4. **Why models fail**: each model's answers split into right, near miss (a score of 80% or more, e.g. 5 of 6 checks), partly right, wrong (a score of 0), cut off at max_tokens or the time limit, no answer or bad format, code that crashed or hung, and critical failures. The Failures tab shows each failure's kind (with its checks passed) and filters by it.
122
123
  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.
123
124
  6. **Question health**: questions every model fails (check them), every model passes (they don't separate models), and the ones that split models most.
124
125
  7. **Next runs**: the Certify runs that would settle the open verdicts of the strongest models, with their time and command.
@@ -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.dev127'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev127')
21
+ __version__ = version = '0.2.0.dev131'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev131')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -351,10 +351,22 @@ def speed(rows, infos, machine=None, speeds=None):
351
351
  return header, table, frontier
352
352
 
353
353
 
354
+ NEAR_MISS = 0.8 # a failed answer scoring at least this (e.g. 5 of 6 checks) is a near miss
355
+
356
+
357
+ def checks_passed(r):
358
+ """(passed, total) checks of a failed code answer (from its stored checks, or its reason), else None."""
359
+ checks = (r.get("raw") or {}).get("checks")
360
+ if checks:
361
+ return sum(v is None for v in checks.values()), len(checks)
362
+ m = re.match(r"Failed (\d+)/(\d+) checks", r.get("reason") or "")
363
+ return (int(m[2]) - int(m[1]), int(m[2])) if m else None
364
+
365
+
354
366
  def per_question(rows):
355
367
  """[{test, name, suite, difficulty, cells: {model: cell}, spread}] with one cell per model that
356
- answered: n answers, passes, median tokens / seconds / tok/s over repeats, truncated count,
357
- partial (some answers from a pack still running), rows (the answers, for the detail view).
368
+ answered: n answers, passes, mean score (partial credit), median tokens / seconds / tok/s over repeats,
369
+ truncated count, partial (some answers from a pack still running), rows (the answers, for the detail view).
358
370
  spread = {"tokens": .., "secs": ..}: largest / smallest model median (1 if one model)."""
359
371
  tests = {}
360
372
  for r in rows:
@@ -369,6 +381,7 @@ def per_question(rows):
369
381
  c.update(rows=v, n=len(v), passed=sum(r["ok"] for r in v), truncated=sum(r["truncated"] for r in v),
370
382
  over=sum(bool(r.get("over_budget")) for r in v),
371
383
  partial=any(r.get("partial") for r in v),
384
+ score=statistics.fmean(r.get("score", r["ok"]) for r in v),
372
385
  tokens=median(r["tokens"] or None for r in v), secs=median(r["latency"] / 1000 or None for r in v),
373
386
  tps=median(r["gen_tps"] for r in v))
374
387
  t["spread"] = _spread(t["cells"])
@@ -390,10 +403,21 @@ def with_models(test, models):
390
403
  return dict(test, cells=cells, spread=_spread(cells))
391
404
 
392
405
 
406
+ def complete_models(rows, suite, test_ids=None):
407
+ """The models whose rows answer every question of a pack: test_ids (the pack's current tests),
408
+ or every question of it seen in the rows. Partial rows (a pack still running) don't count."""
409
+ rows = [r for r in rows if r["suite"] == suite and not r.get("partial")]
410
+ wanted = {f"{suite}: {t}" for t in test_ids} if test_ids is not None else {r["test"] for r in rows}
411
+ answered = {}
412
+ for r in rows:
413
+ answered.setdefault(r["model"], set()).add(r["test"])
414
+ return sorted(m for m, tests in answered.items() if wanted and wanted <= tests)
415
+
416
+
393
417
  def compare_models(tests, models):
394
418
  """Per model over per_question entries that all `models` answered: passes, token and time
395
419
  totals/medians (totals add each question's median over repeats, so repeats don't inflate them), questions where it used the fewest tokens / was fastest (among passing
396
- answers for tokens; ties count for each), answers cut off."""
420
+ answers for tokens; ties count for each), answers cut off, mean score (partial credit)."""
397
421
  out = []
398
422
  for m in models:
399
423
  cs = [t["cells"][m] for t in tests]
@@ -410,6 +434,7 @@ def compare_models(tests, models):
410
434
  fastest += 1
411
435
  rs = [r for c in cs for r in c["rows"]]
412
436
  out.append({"model": m, "n": len(cs), "passed": passed, "total_tokens": total,
437
+ "score": statistics.fmean(c["score"] for c in cs) if cs else None,
413
438
  "avg_pp_tps": _rate([(r.get("prompt_tokens"), r["prompt_tps"]) for r in rs]),
414
439
  "avg_gen_tps": _rate([(r["tokens"], r["gen_tps"]) for r in rs]),
415
440
  "med_tokens": median(c["tokens"] for c in cs), "med_secs": median(c["secs"] for c in cs),
@@ -6,8 +6,9 @@
6
6
  2 use cases accuracy per model and use case (pack group) as a heatmap; the best marked
7
7
  3 vs base a fine-tune against its base model (models.toml `base`): per pack, and the question
8
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
9
+ 4 why they fail each model's answers split into right, near miss (most checks passed), partly right,
10
+ wrong, cut off (or out of time), no answer / bad format, code crashed or hung, and
11
+ critical failures
11
12
  5 accuracy / cost accuracy against median tokens (or seconds) per question; the models no other beats
12
13
  on both
13
14
  6 questions questions every model fails (check them), every model passes (no signal), and the
@@ -267,7 +268,8 @@ def categories(rows, m, base, min_q=2):
267
268
 
268
269
 
269
270
  # ---------------------------------------------------------------- 4 why they fail
270
- KINDS = (("right", "green", "█"), ("wrong", "red", "█"), ("cut off", "yellow", "█"),
271
+ KINDS = (("right", "green", "█"), ("near miss", "#87af5f", "█"), ("partly right", "#af875f", "█"),
272
+ ("wrong", "red", "█"), ("cut off", "yellow", "█"),
271
273
  ("no answer / format", "magenta", "█"), ("code crashed or hung", "cyan", "█"), ("critical", "#ff5faf", "▓"))
272
274
  FORMAT = ("No 'ANSWER:' line", "Empty answer", "No code", "no tool call")
273
275
  CRASHED = ("Timed out after", "Harness crashed", "grader error")
@@ -278,13 +280,15 @@ def kind(r):
278
280
  return "right"
279
281
  if r.get("severity") == "critical":
280
282
  return "critical"
281
- if r["truncated"] or r["reason"].startswith(("TRUNCATED", "OVER BUDGET")):
283
+ if r["truncated"] or r["reason"].startswith(("TRUNCATED", "OVER BUDGET", "time limit")):
282
284
  return "cut off"
283
285
  if r["reason"].startswith(FORMAT):
284
286
  return "no answer / format"
285
287
  if r["reason"].startswith(CRASHED):
286
288
  return "code crashed or hung"
287
- return "wrong"
289
+ # partial credit (e.g. checks passed): close isn't the same as all wrong
290
+ score = r.get("score") or 0
291
+ return "near miss" if score >= compare.NEAR_MISS else "partly right" if score > 0 else "wrong"
288
292
 
289
293
 
290
294
  def _split(counts, total, width):
@@ -322,7 +326,9 @@ def failure_mix(rows, models, width=36):
322
326
  top, n = max(((k, c[k]) for k, *_ in KINDS[1:-1]), key=lambda x: x[1])
323
327
  if n / fails >= 0.4 and top != "wrong":
324
328
  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.",
329
+ "cut off": "it thinks too long for max_tokens (or the time limit), rather than reasoning wrong.",
330
+ "near miss": "they pass most of their checks: it's closer than its accuracy shows.",
331
+ "partly right": "they pass some of their checks, not none.",
326
332
  "no answer / format": "it doesn't follow the answer format (check its chat template and the "
327
333
  "pack's instructions).",
328
334
  "code crashed or hung": "its code crashes or never finishes."}[top])
@@ -7,6 +7,9 @@
7
7
  model's ("28×").
8
8
  A accuracy the share of answers to those questions that passed (%).
9
9
 
10
+ Beside A, the score: partial credit averaged over the same answers (e.g. a code answer passing 5 of 6
11
+ checks scores 83%, where A counts it wrong). Shown when it differs from A; models can be ordered by it.
12
+
10
13
  The P and T bars are as long as the tokens and time used (the model that used the most fills its bar,
11
14
  the others in proportion, at least one block), so shorter is better; the A bar: longer is better.
12
15
  P and T also carry a 0-100 score (100 for the best, POINTS_PER_DOUBLING points less for every
@@ -46,11 +49,15 @@ def memory_gb(infos):
46
49
  return out
47
50
 
48
51
 
49
- def index(rows, models=None, memory=None):
50
- """{"questions": n shared, "models": [{model, P, T, A, cut, cut_pct, tokens, total_s, passed, answers, alongside,
52
+ SORTS = ("accuracy", "score")
53
+
54
+
55
+ def index(rows, models=None, memory=None, sort="accuracy"):
56
+ """{"questions": n shared, "models": [{model, P, T, A, S, cut, cut_pct, tokens, total_s, passed, answers, alongside,
51
57
  fewest, fastest, tps, ttft, memory_gb}],
52
58
  "left_out": [(model, questions answered)], "no_answers": [model], "most": questions answered by the
53
- most-answered model}, best accuracy first. models: the ones to compare (default: every model in rows
59
+ most-answered model}, best accuracy (sort="accuracy") or score (sort="score") first. S: the mean
60
+ score (partial credit) in %. models: the ones to compare (default: every model in rows
54
61
  with enough answers; models named here are compared whatever their count). memory: memory_gb()."""
55
62
  no_answers = sorted({r["model"] for r in rows} - {r["model"] for r in rows if not r.get("error")})
56
63
  rows = [r for r in rows if not r.get("error")] # a server or connection error isn't an answer
@@ -75,6 +82,7 @@ def index(rows, models=None, memory=None):
75
82
  "total_s": sum(c["secs"] or 0 for c in cells),
76
83
  "passed": sum(c["passed"] for c in cells), "answers": answers,
77
84
  "A": 100 * sum(c["passed"] for c in cells) / answers if answers else None,
85
+ "S": 100 * sum(c["score"] * c["n"] for c in cells) / answers if answers else None,
78
86
  # failed only because max_tokens ran out (overthinking, not wrong yet)
79
87
  "cut": sum(c["truncated"] for c in cells),
80
88
  "cut_pct": 100 * sum(c["truncated"] for c in cells) / answers if answers else None,
@@ -95,7 +103,8 @@ def index(rows, models=None, memory=None):
95
103
  x["time_x"] = x["total_s"] / fastest if fastest and x["total_s"] else None
96
104
  x["fewest"] = bool(fewest) and x["tokens"] == fewest
97
105
  x["fastest"] = bool(fastest) and x["total_s"] == fastest
98
- out.sort(key=lambda x: (-(x["A"] or 0), -(x["T"] or 0), x["model"]))
106
+ first, then = ("S", "A") if sort == "score" else ("A", "S")
107
+ out.sort(key=lambda x: (-(x[first] or 0), -(x[then] or 0), -(x["T"] or 0), x["model"]))
99
108
  return {"questions": len(shared), "models": out, "left_out": left_out, "no_answers": no_answers,
100
109
  "most": most}
101
110
 
@@ -145,6 +154,10 @@ def bars(result, width=20, markup=True, click=None):
145
154
  if v is not None and v < 100: # full only when every answer passed
146
155
  full = min(full, width - 1)
147
156
  num = "-" if v is None else compare.pct(v / 100)
157
+ score = x.get("S")
158
+ if score is not None and v is not None and compare.pct(score / 100) != num:
159
+ note = f"score {compare.pct(score / 100)}"
160
+ num = f"{num:>6} " + (f"[dim]{note}[/dim]" if markup else note)
148
161
  cut = x.get("cut_pct") or 0
149
162
  part = min(width - full, max(1, round(width * cut / 100))) if cut else 0
150
163
  if cut: # after the accuracy, which stays aligned with the other rows'
@@ -189,11 +202,12 @@ def table(result):
189
202
  """(header, rows) for a plain table of the index."""
190
203
  n = result["questions"] or 1
191
204
  over = any(x.get("over") for x in result["models"]) # a column only when some answer ran past max_tokens
192
- header = ["model", "tokens/answer", "time/answer", "answers right", "truncated", *(["over budget"] if over else []),
205
+ header = ["model", "tokens/answer", "time/answer", "answers right", "score", "truncated", *(["over budget"] if over else []),
193
206
  "tok/s", "TTFT s", "memory GB"]
194
207
  rows = [[x["model"], f"{round(x['tokens'] / n):,}" + (" ★" if x.get("fewest") else ""),
195
208
  fmt_secs(x["total_s"] / n) + (" ★" if x.get("fastest") else ""),
196
- f"{x['passed']}/{x['answers']}", f"{x.get('cut') or 0}/{x['answers']}",
209
+ f"{x['passed']}/{x['answers']}", "-" if x.get("S") is None else compare.pct(x["S"] / 100),
210
+ f"{x.get('cut') or 0}/{x['answers']}",
197
211
  *([f"{x.get('over') or 0}/{x['answers']}"] if over else []),
198
212
  _num(x.get("tps"), ".0f"), _num(x.get("ttft"), ".1f"),
199
213
  _num(x.get("memory_gb"), ".1f")] for x in result["models"]]
@@ -228,7 +242,7 @@ def scope_note(result, models):
228
242
  return (f"Compared on the {result['questions']} questions all {len(result['models'])} model(s) answered. "
229
243
  "P parsimony (tokens) and T time: bars as long as the tokens / time used (shorter is better), "
230
244
  "and how many times the leanest / fastest model's (1.0×, ★ in the table). A accuracy: answers right "
231
- "(longer is better)"
245
+ "(longer is better); score: partial credit, e.g. 5 of 6 checks passed is 83% (shown when it differs)"
232
246
  + ("; orange, (n% truncated): answers cut off at max_tokens. They count as wrong, but the model ran "
233
247
  "out of tokens rather than answering wrong." if any(x.get("cut") for x in result["models"]) else ".")
234
248
  + (" Over budget: answers that used more tokens than the max_tokens sent, because the provider didn't "
@@ -236,14 +250,14 @@ def scope_note(result, models):
236
250
  if any(x.get("over") for x in result["models"]) else ""))
237
251
 
238
252
 
239
- def for_engine(e, labels=None, packs=None, results_dir=None):
253
+ def for_engine(e, labels=None, packs=None, results_dir=None, sort="accuracy"):
240
254
  """(result, models) from a workspace's finished results (results on their way out excluded)."""
241
255
  paths = [p for p in compare.default_paths(results_dir or e.results_dir) if not e.superseded(p)]
242
256
  rows, infos = compare.load(paths) if paths else ([], [])
243
257
  if packs:
244
258
  rows = [r for r in rows if r["suite"] in packs]
245
259
  models = sorted({r["model"] for r in rows} & set(labels) if labels else {r["model"] for r in rows})
246
- return index(rows, models if labels else None, memory_gb(infos)), models
260
+ return index(rows, models if labels else None, memory_gb(infos), sort), models
247
261
 
248
262
 
249
263
  def markdown(result, models):
@@ -402,6 +402,8 @@ def cmd_pta(argv):
402
402
  p.add_argument("--only", help="comma-separated model labels (default: every model with results)")
403
403
  p.add_argument("--packs", help="comma-separated packs (default: all)")
404
404
  p.add_argument("--width", type=int, default=20, help="bar width in characters (default 20)")
405
+ p.add_argument("--sort", choices=("accuracy", "score"), default="accuracy",
406
+ help="order models by answers right (default) or by score (partial credit)")
405
407
  p.add_argument("--models")
406
408
  p.add_argument("--results-dir")
407
409
  a = p.parse_args(argv)
@@ -410,7 +412,7 @@ def cmd_pta(argv):
410
412
  e = engine.Engine(models_path=a.models, results_dir=a.results_dir)
411
413
  labels = [x.strip() for x in a.only.split(",")] if a.only else None
412
414
  packs = [x.strip() for x in a.packs.split(",")] if a.packs else None
413
- result, models = pta.for_engine(e, labels, packs)
415
+ result, models = pta.for_engine(e, labels, packs, sort=a.sort)
414
416
  out = Console(highlight=False)
415
417
  if not models:
416
418
  sys.exit("no finished results to compare" + (" for those models or packs" if labels or packs else ""))
@@ -2892,6 +2892,10 @@ class ResultsScreen(Screen):
2892
2892
  def action_runs(self):
2893
2893
  self.app.open_sessions()
2894
2894
 
2895
+ FAIL_KINDS = [("Every kind", ""), ("Near misses", "near miss"), ("Partly right", "partly right"),
2896
+ ("Wrong", "wrong"), ("Cut off", "cut off"), ("No answer / format", "no answer / format"),
2897
+ ("Code crashed or hung", "code crashed or hung"), ("Critical", "critical")]
2898
+
2895
2899
  READINESS_HELP = ("[dim]PASS needs a Certify run with zero critical failures over enough trials and an "
2896
2900
  "accuracy lower bound that clears the pack's gate (pack.toml). Screening can only FAIL "
2897
2901
  "or look promising.[/dim]")
@@ -2913,6 +2917,8 @@ class ResultsScreen(Screen):
2913
2917
  with TabPane("PTA index", id="tab-pta"):
2914
2918
  with Horizontal(id="pta-controls"):
2915
2919
  yield Select([("All packs", "")], id="pta-pack", allow_blank=False, value="")
2920
+ yield Select([("By accuracy", "accuracy"), ("By score", "score")], id="pta-sort",
2921
+ allow_blank=False, value="accuracy")
2916
2922
  yield Button("Models: all", id="pta-models")
2917
2923
  yield Static("[dim]P parsimony (tokens): tokens used, reasoning and answer. T time: total time. "
2918
2924
  "A accuracy: answers right. Verdicts and critical failures are in Production "
@@ -2954,6 +2960,7 @@ class ResultsScreen(Screen):
2954
2960
  with TabPane("Failures", id="tab-failures"):
2955
2961
  with Horizontal(id="fail-controls"):
2956
2962
  yield Button("Models: all", id="fail-models")
2963
+ yield Select(self.FAIL_KINDS, id="fail-kind", allow_blank=False, value="")
2957
2964
  yield Static(id="fail-count")
2958
2965
  yield Static("[dim]Enter on a row shows the whole answer, its reasoning and what was checked.[/dim]")
2959
2966
  yield DataTable(id="failures", zebra_stripes=True, cursor_type="row")
@@ -2963,6 +2970,9 @@ class ResultsScreen(Screen):
2963
2970
  self.rows, self.infos, self.partial_rows, self.superseded = [], [], [], set()
2964
2971
  if not hasattr(self.app, "pq_models"):
2965
2972
  self.app.pq_models = set()
2973
+ if getattr(self.app, "pack_pick", None): # the pack lists open on All packs again
2974
+ self.app.pq_models = self.app.pack_pick["before"]
2975
+ self.app.pack_pick = None
2966
2976
  if not hasattr(self.app, "result_sorts"):
2967
2977
  self.app.result_sorts = {} # table id -> (column name, descending), kept for this session
2968
2978
  self.fill()
@@ -3042,24 +3052,40 @@ class ResultsScreen(Screen):
3042
3052
  self.sort_tables()
3043
3053
 
3044
3054
  def fill_failures(self):
3045
- """Every wrong answer, of the models picked (Models: shared with the other tabs)."""
3055
+ """Every wrong answer, of the models picked (Models: shared with the other tabs), of the kind picked
3056
+ (insights.kind: near miss, partly right, wrong, cut off, …)."""
3057
+ from . import insights
3046
3058
  f = self.query_one("#failures", DataTable)
3047
3059
  f.clear(columns=True)
3048
- f.add_columns("Model", "Level", "Test", "Answered", "Reason")
3060
+ f.add_columns("Model", "Level", "Test", "Answered", "Kind", "Reason")
3049
3061
  every = {r["model"] for r in self.rows}
3050
3062
  chosen = {m for m in self.app.pq_models if m in every}
3051
- rows = [r for r in self.rows if not chosen or r["model"] in chosen]
3063
+ wanted = self.query_one("#fail-kind", Select).value or ""
3064
+ rows = [r for r in self.rows if (not chosen or r["model"] in chosen)
3065
+ and (r["ok"] or not wanted or insights.kind(r) == wanted)]
3052
3066
  # compare.failures lists the failed rows in order, so they line up with fail_rows
3053
3067
  self.fail_rows = [r for r in rows if not r["ok"]]
3068
+ colors = {k: c for k, c, _ in insights.KINDS}
3054
3069
  for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
3055
- when = dates.day(dates.answered_at(self.fail_rows[i].get("raw") or {}))
3056
- f.add_row(model, diff_badge(level), rich_escape(test), when, rich_escape(reason[:130]), key=str(i))
3057
- self.query_one("#fail-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
3070
+ r = self.fail_rows[i]
3071
+ when = dates.day(dates.answered_at(r.get("raw") or {}))
3072
+ k = insights.kind(r)
3073
+ checks = compare.checks_passed(r)
3074
+ label = f"{k} {checks[0]}/{checks[1]}" if checks and k in ("near miss", "partly right", "wrong") else k
3075
+ f.add_row(model, diff_badge(level), rich_escape(test), when, f"[{colors[k]}]{label}[/]",
3076
+ rich_escape(reason[:130]), key=str(i))
3077
+ self.query_one("#fail-models", Button).label = self.models_label(chosen)
3058
3078
  n = len(self.fail_rows)
3059
3079
  self.query_one("#fail-count", Static).update(
3060
- f"{n} failure{'s' * (n != 1)}" + (f" of {len(chosen)} model{'s' * (len(chosen) != 1)}" if chosen else ""))
3080
+ f"{n} {'' if not wanted else wanted + ' '}failure{'s' * (n != 1)}"
3081
+ + (f" of {len(chosen)} model{'s' * (len(chosen) != 1)}" if chosen else ""))
3061
3082
  self.sort_table(f)
3062
3083
 
3084
+ @on(Select.Changed, "#fail-kind")
3085
+ def fail_kind_changed(self):
3086
+ if getattr(self, "rows", None) is not None:
3087
+ self.fill_failures()
3088
+
3063
3089
  @on(DataTable.RowSelected, "#failures")
3064
3090
  def failure_selected(self, event):
3065
3091
  # in the order shown, so [ and ] step through them as sorted
@@ -3072,7 +3098,6 @@ class ResultsScreen(Screen):
3072
3098
  self.app.push_screen(AnswerScreen(keep, keep.index(entries[i]), what="failure"))
3073
3099
 
3074
3100
  # -- per question: tokens and time for every model on the same question
3075
- @on(Select.Changed, "#pq-pack")
3076
3101
  @on(Select.Changed, "#pq-level")
3077
3102
  @on(Checkbox.Changed, "#pq-partial")
3078
3103
  @on(Checkbox.Changed, "#pq-shared")
@@ -3214,18 +3239,62 @@ class ResultsScreen(Screen):
3214
3239
  finally:
3215
3240
  self.filling_insights = False
3216
3241
  chosen = {m for m in self.app.pq_models if m in every}
3217
- self.query_one("#ins-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
3242
+ self.query_one("#ins-models", Button).label = self.models_label(chosen)
3218
3243
  main = os.path.abspath(self.results_dir or e.results_dir) == os.path.abspath(e.results_dir)
3219
3244
  bases = {m["label"]: m["base"] for m in e.cfg["models"] if m.get("base")}
3220
3245
  by = self.query_one("#ins-cost", Select).value or "tokens"
3221
3246
  out = insights.panels(rows, models, e.packs, ref, bases, by, engine=e if main else None)
3222
3247
  body.update("\n\n".join(insights.render(p, i) for i, p in enumerate(out, 1)))
3223
3248
 
3224
- @on(Select.Changed, "#pta-pack")
3225
- def pta_changed(self):
3249
+ @on(Select.Changed, "#pta-sort")
3250
+ def pta_sort_changed(self):
3251
+ """The Sort dropdown orders the bars and the table, taking over from a column sorted by a click."""
3252
+ self.app.result_sorts.pop("pta", None)
3226
3253
  if getattr(self, "rows", None) is not None:
3227
3254
  self.fill_pta()
3228
3255
 
3256
+ @on(Select.Changed, "#pta-pack")
3257
+ @on(Select.Changed, "#pq-pack")
3258
+ def pack_changed(self, event):
3259
+ """A pack picked (PTA index or Per question, kept the same in both): compare the models that
3260
+ answered every question of it (the Models button can change that); All packs: back to the
3261
+ models picked before."""
3262
+ if getattr(self, "rows", None) is None:
3263
+ return
3264
+ e, pack = self.app.engine, event.value or ""
3265
+ for other, values in (("#pta-pack", "pta_pack_values"), ("#pq-pack", "pq_pack_values")):
3266
+ sel = self.query_one(other, Select)
3267
+ if sel is not event.select and sel.value != pack and pack in getattr(self, values, [""]):
3268
+ with sel.prevent(Select.Changed):
3269
+ sel.value = pack
3270
+ shared = self.query_one("#pq-shared", Checkbox)
3271
+ p = self.app.pack_pick or {"before": set(self.app.pq_models), "shared": shared.value}
3272
+ before = p["before"]
3273
+ if not pack:
3274
+ self.app.pack_pick = None
3275
+ with shared.prevent(Checkbox.Changed): # set_models may have ticked it
3276
+ shared.value = p["shared"]
3277
+ self.set_models(before)
3278
+ return
3279
+ rows = [r for r in self.rows if r.get("path") not in self.superseded]
3280
+ complete = set(compare.complete_models(rows, pack, [t["id"] for t in e.packs[pack].tests]
3281
+ if pack in e.packs else None))
3282
+ self.app.pack_pick = {"pack": pack, "models": complete, "before": before, "shared": p["shared"]}
3283
+ if not complete:
3284
+ self.notify(f"No model has answered every question of {self.pack_label(pack)} yet: "
3285
+ "the models picked before are kept.", severity="warning", timeout=8)
3286
+ self.set_models(complete or before)
3287
+
3288
+ def pack_label(self, pack):
3289
+ return self.app.engine.packs[pack].label if pack in self.app.engine.packs else pack
3290
+
3291
+ def models_label(self, chosen):
3292
+ """The Models buttons' text: the models a pack pick chose say so."""
3293
+ p = self.app.pack_pick
3294
+ if chosen and p and chosen == p["models"]:
3295
+ return f"Models: {len(chosen)} complete on {self.pack_label(p['pack'])}"
3296
+ return f"Models: {len(chosen)} picked" if chosen else "Models: all"
3297
+
3229
3298
  def fill_pta(self):
3230
3299
  """The PTA index (pta.py) of the picked models (Models button, shared with Per question) on
3231
3300
  the finished results, over the questions all of them answered."""
@@ -3245,7 +3314,7 @@ class ResultsScreen(Screen):
3245
3314
  rows = [r for r in rows if r["suite"] == sel.value]
3246
3315
  present = {r["model"] for r in rows}
3247
3316
  chosen = {m for m in self.app.pq_models if m in present}
3248
- self.query_one("#pta-models", Button).label = f"Models: {len(chosen)} picked" if chosen else "Models: all"
3317
+ self.query_one("#pta-models", Button).label = self.models_label(chosen)
3249
3318
  t = self.query_one("#pta", DataTable)
3250
3319
  t.clear(columns=True)
3251
3320
  if not present:
@@ -3254,7 +3323,8 @@ class ResultsScreen(Screen):
3254
3323
  self.query_one("#pta-left-out", Static).update("")
3255
3324
  return
3256
3325
  # none picked: every model with enough answers (pta.index leaves out the rest, and says so)
3257
- result = pta.index(rows, sorted(chosen) or None, pta.memory_gb(self.infos))
3326
+ result = pta.index(rows, sorted(chosen) or None, pta.memory_gb(self.infos),
3327
+ self.query_one("#pta-sort", Select).value or "accuracy")
3258
3328
  self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
3259
3329
  self.pta_result = result
3260
3330
  removed = [m for m in self.pta_removed_now() if m in present]
@@ -3300,8 +3370,7 @@ class ResultsScreen(Screen):
3300
3370
  all_tests = compare.per_question(rows)
3301
3371
  self.pq_all_tests = all_tests
3302
3372
  chosen = {m for m in self.app.pq_models if any(m in t["cells"] for t in all_tests)}
3303
- self.query_one("#pq-models", Button).label = (
3304
- f"Models: {len(chosen)} picked" if chosen else "Models: all")
3373
+ self.query_one("#pq-models", Button).label = self.models_label(chosen)
3305
3374
  tests = []
3306
3375
  for t in all_tests:
3307
3376
  if (pack and t["suite"] != pack) or (level and t["difficulty"] != level):
@@ -3401,7 +3470,8 @@ class ResultsScreen(Screen):
3401
3470
  tt = r["total_secs"]
3402
3471
  cells.append([r["model"],
3403
3472
  hi(tt, ("-" if not tt else f"{tt:.1f}s" if tt < 60 else fmt_secs(tt)), "total_secs", "min"),
3404
- hi(r["passed"], f"{r['passed']}/{r['n']}", "passed", "max"),
3473
+ hi(r["passed"], f"{r['passed']}/{r['n']}", "passed", "max")
3474
+ + (f" [dim]{r['score']:.0%}[/dim]" if r["passed"] < r["n"] and r["score"] else ""),
3405
3475
  hi(r["med_tokens"], f"{r['med_tokens']:,.0f}" if r["med_tokens"] else "-", "med_tokens", "min"),
3406
3476
  hi(r["total_tokens"], f"{r['total_tokens']:,.0f}", "total_tokens", "min"),
3407
3477
  hi(r["tokens_per_pass"], f"{r['tokens_per_pass']:,.0f}" if r["tokens_per_pass"] else "-",
@@ -3431,7 +3501,7 @@ class ResultsScreen(Screen):
3431
3501
  "shared the machine's speed: compare their times with care.[/yellow]")
3432
3502
  title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
3433
3503
  "per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
3434
- "they took (cached prompts left out). Fewest / Fastest: questions won; ties count for both. "
3504
+ "they took (cached prompts left out). Passed: questions right on every repeat, then the score (partial credit, e.g. checks passed) when some failed. Fewest / Fastest: questions won; ties count for both. "
3435
3505
  "Cut off: answers that hit max_tokens.[/dim]")
3436
3506
 
3437
3507
  @staticmethod
@@ -3440,6 +3510,9 @@ class ResultsScreen(Screen):
3440
3510
  else f"[yellow]{c['passed']}/{c['n']}[/yellow]")
3441
3511
  if c["n"] > 1 and c["passed"] in (0, c["n"]):
3442
3512
  mark += f"[dim]{c['n']}[/dim]"
3513
+ if c["passed"] < c["n"] and c.get("score"): # partial credit: how close it came
3514
+ checks = compare.checks_passed(c["rows"][0]) if c["n"] == 1 else None
3515
+ mark += f" [yellow]{checks[0]}/{checks[1]}[/yellow]" if checks else f" [yellow]{c['score']:.0%}[/yellow]"
3443
3516
  tok = c["tokens"]
3444
3517
  tok_s = "-" if not tok else (f"{tok / 1000:.1f}k" if tok >= 1000 else f"{tok:.0f}")
3445
3518
  if tok and best and tok == best and c["passed"]:
@@ -3586,6 +3659,7 @@ class EvalsApp(App):
3586
3659
  #ins-controls #ins-cost { width: 20; }
3587
3660
  #ins-body { height: 1fr; }
3588
3661
  #pta-controls Select { width: 28; margin-right: 1; }
3662
+ #pta-controls #pta-sort { width: 18; }
3589
3663
  #pta-bars { height: auto; padding: 1 0 0 0; }
3590
3664
  #pta { width: 1fr; height: auto; margin-top: 1; }
3591
3665
  #pta-left-out { height: auto; padding: 1 0 0 0; }
@@ -3594,6 +3668,7 @@ class EvalsApp(App):
3594
3668
  #pq-models { margin-right: 1; min-width: 20; }
3595
3669
  #fail-controls { height: 3; }
3596
3670
  #fail-models { margin-right: 1; min-width: 20; }
3671
+ #fail-kind { width: 28; margin-right: 1; }
3597
3672
  #fail-count { width: auto; padding: 1 1; }
3598
3673
  #pq-compare-title { padding: 0 1; height: auto; }
3599
3674
  #pq-summary { height: auto; } /* every picked model, no scrolling: the tab scrolls instead */
@@ -1618,8 +1618,8 @@ class PTAIndex(unittest.TestCase):
1618
1618
  self.assertEqual(bars["plain"][-2:], ["██████░░░░", "60.0%"]) # none truncated: nothing beside it
1619
1619
  self.assertIn("[#ff8c00]██[/]", pta.bars(res, 10)[2] + pta.bars(res, 10)[3])
1620
1620
  header, table = pta.table(res)
1621
- self.assertEqual(header[4], "truncated")
1622
- self.assertEqual({r[0]: r[4] for r in table}, {"thinker": "2/10", "plain": "0/10"})
1621
+ self.assertEqual(header[4:6], ["score", "truncated"])
1622
+ self.assertEqual({r[0]: r[5] for r in table}, {"thinker": "2/10", "plain": "0/10"})
1623
1623
  self.assertIn("truncated", pta.scope_note(res, ["thinker", "plain"]))
1624
1624
  self.assertNotIn("truncated", pta.scope_note(pta.index(rows[10:]), ["plain"]))
1625
1625
 
@@ -1656,6 +1656,159 @@ class PTAIndex(unittest.TestCase):
1656
1656
  self.assertEqual(sorted(cells, key=sort_key), ["10/20", "3/4", "4/4", "abc", "-"])
1657
1657
  self.assertEqual(sorted(cells, key=lambda c: sort_key(c, True)), ["4/4", "3/4", "10/20", "abc", "-"])
1658
1658
 
1659
+ def test_partial_credit_score(self):
1660
+ from tuieval import compare, insights, pta
1661
+
1662
+ def row(model, test, ok, score, reason="Failed", **kw):
1663
+ return {"model": model, "suite": "p", "test": f"p: {test}", "ok": ok, "score": score, "latency": 1000,
1664
+ "tokens": 10, "truncated": False, "gen_tps": None, "repeat": 0, "reason": reason, **kw}
1665
+ rows = ([row("close", f"q{i}", False, 5 / 6, "Failed 1/6 checks: x") for i in range(2)]
1666
+ + [row("far", f"q{i}", False, 0.0, "Failed 6/6 checks: x") for i in range(2)]
1667
+ + [row("half", "q0", True, 1.0, "ok"), row("half", "q1", False, 0.0, "Failed 6/6 checks: x")])
1668
+ res = pta.index(rows)
1669
+ self.assertEqual([(x["model"], x["A"], round(x["S"])) for x in res["models"]],
1670
+ [("half", 50, 50), ("close", 0, 83), ("far", 0, 0)]) # accuracy first, then score
1671
+ self.assertEqual([x["model"] for x in pta.index(rows, sort="score")["models"]], ["close", "half", "far"])
1672
+ bars = {l.split()[0]: l for l in pta.bars(res, 10, markup=False)[2:]}
1673
+ self.assertIn("0% score 83.3%", bars["close"])
1674
+ self.assertNotIn("score", bars["half"] + bars["far"]) # shown only when it differs
1675
+ header, table = pta.table(res)
1676
+ self.assertEqual({r[0]: r[header.index("score")] for r in table}, {"half": "50.0%", "close": "83.3%", "far": "0%"})
1677
+ self.assertEqual([compare.checks_passed(r) for r in (rows[0], rows[2], rows[4])], [(5, 6), (0, 6), None])
1678
+ self.assertEqual(compare.checks_passed({"reason": "x", "raw": {"checks": {"a": None, "b": "err"}}}), (1, 2))
1679
+ self.assertEqual([insights.kind(r) for r in (rows[0], rows[2], rows[4])], ["near miss", "wrong", "right"])
1680
+ self.assertEqual(insights.kind(row("m", "q", False, 0.5)), "partly right")
1681
+ self.assertEqual(insights.kind(row("m", "q", False, 0.0, "time limit: timed out after 600 s in all")), "cut off")
1682
+ self.assertEqual(insights.kind(row("m", "q", False, 0.9, severity="critical")), "critical")
1683
+ p = insights.failure_mix(rows, ["close", "far", "half"])
1684
+ self.assertIn("0% right (2 answers; 2 near miss)", re.sub(r"\[[^\]]*\]", "", "\n".join(p["lines"])))
1685
+
1686
+ def test_results_show_partial_credit(self):
1687
+ code = textwrap.dedent("""
1688
+ import asyncio
1689
+ from tuieval.tui import EvalsApp, ResultsScreen
1690
+ from textual.widgets import DataTable, Select, Static, TabbedContent
1691
+ def row(model, test, ok, score, reason):
1692
+ return {"model": model, "suite": "p", "test": f"p: {test}", "name": f"p: {test}", "ok": ok,
1693
+ "score": score, "reason": reason, "error": False, "truncated": False, "output": "",
1694
+ "tokens": 10, "latency": 1000, "gen_tps": None, "prompt_tps": None, "ttft": None,
1695
+ "repeat": 0, "difficulty": "hard", "raw": {}, "path": "x", "alongside": []}
1696
+ rows = ([row("close", f"q{i}", False, 5 / 6, "Failed 1/6 checks: x") for i in range(2)]
1697
+ + [row("half", "q0", True, 1.0, "ok"), row("half", "q1", False, 0.0, "Failed 6/6 checks: x")])
1698
+ async def go():
1699
+ app = EvalsApp({})
1700
+ async with app.run_test(size=(180, 50)) as pilot:
1701
+ await pilot.pause()
1702
+ app.push_screen(ResultsScreen())
1703
+ await pilot.pause(0.5)
1704
+ s = app.screen
1705
+ s.rows, s.infos = rows, []
1706
+ s.fill_pta()
1707
+ await pilot.pause(0.2)
1708
+ first = s.pta_result["models"][0]["model"]
1709
+ s.query_one("#pta-sort", Select).value = "score"
1710
+ await pilot.pause(0.3)
1711
+ print(first, s.pta_result["models"][0]["model"])
1712
+ s.fill_failures()
1713
+ t = s.query_one("#failures", DataTable)
1714
+ print(sorted(str(t.get_row_at(i)[4]) for i in range(t.row_count)))
1715
+ s.query_one("#fail-kind", Select).value = "near miss"
1716
+ await pilot.pause(0.3)
1717
+ print(t.row_count, str(s.query_one("#fail-count", Static).render()))
1718
+ s.fill_per_question()
1719
+ pq = s.query_one("#per-question", DataTable)
1720
+ cols = [str(c.label) for c in pq.ordered_columns]
1721
+ print(sorted(str(pq.get_row_at(i)[cols.index("close")]).split()[1] for i in range(pq.row_count)))
1722
+ asyncio.run(go())
1723
+ """)
1724
+ with tempfile.TemporaryDirectory() as tmp:
1725
+ ws = os.path.join(tmp, "ws")
1726
+ tuieval(tmp, "init", ws)
1727
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
1728
+ env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
1729
+ self.assertEqual(out.stdout.strip().splitlines(), [
1730
+ "half close", # by accuracy, then by score
1731
+ "['[#87af5f]near miss 5/6[/]', '[#87af5f]near miss 5/6[/]', '[red]wrong 0/6[/]']",
1732
+ "2 2 near miss failures",
1733
+ "['5/6', '5/6']"], out.stderr[-2000:])
1734
+
1735
+ def test_complete_models(self):
1736
+ from tuieval import compare
1737
+ rows = [{"model": m, "suite": s, "test": f"{s}: {t}", **({"partial": True} if m == "running" else {})}
1738
+ for m, s, ts in (("full", "p", "abc"), ("half", "p", "ab"), ("running", "p", "abc"), ("full", "o", "a"))
1739
+ for t in ts]
1740
+ self.assertEqual(compare.complete_models(rows, "p", ["a", "b", "c"]), ["full"]) # half misses c; running is ◐
1741
+ self.assertEqual(compare.complete_models(rows, "p", ["a", "b"]), ["full", "half"])
1742
+ self.assertEqual(compare.complete_models(rows, "p", ["a", "b", "c", "d"]), []) # a question added since
1743
+ self.assertEqual(compare.complete_models(rows, "p"), ["full"]) # pack gone: questions seen
1744
+ self.assertEqual(compare.complete_models(rows, "x", []), [])
1745
+
1746
+ def test_results_pack_picks_the_models_that_answered_all_of_it(self):
1747
+ with tempfile.TemporaryDirectory() as tmp:
1748
+ ws = os.path.join(tmp, "ws")
1749
+ tuieval(tmp, "init", ws)
1750
+ for name, ids in (("apps", "abcd"), ("other", "ab")):
1751
+ d = os.path.join(ws, "packs", name)
1752
+ os.makedirs(d)
1753
+ write(os.path.join(d, "pack.toml"), f'label = "{name.title()}"\n[certify]\nrepeat = 1\n')
1754
+ write(os.path.join(d, "tests.yaml"), "".join(
1755
+ f"- {{id: {t}, input: question {t}, expected: 1, reference: 'ANSWER: 1', wrong: ['ANSWER: 2'], "
1756
+ "difficulty: easy}\n" for t in ids))
1757
+ mock = Mock(os.path.join(ws, "packs"), "oracle")
1758
+ try:
1759
+ with open(os.path.join(ws, "models.toml"), "a") as f:
1760
+ f.write(f'\n[servers.here]\nurl = "http://127.0.0.1:{mock.port}"\n')
1761
+ for label in ("full", "half", "solo"):
1762
+ tuieval(ws, "add", "mock", "--server", "here", "--label", label)
1763
+ tuieval(ws, "run", "--tier", "certify", "--only", "full")
1764
+ tuieval(ws, "run", "--tier", "certify", "--only", "half", "--packs", "apps", "--tests", "a,b")
1765
+ tuieval(ws, "run", "--tier", "certify", "--only", "solo", "--packs", "other")
1766
+ finally:
1767
+ mock.stop()
1768
+ code = textwrap.dedent("""
1769
+ import asyncio
1770
+ from tuieval.tui import EvalsApp, ResultsScreen
1771
+ from textual.widgets import Button, DataTable, Select
1772
+ async def go():
1773
+ app = EvalsApp({})
1774
+ async with app.run_test(size=(180, 50)) as pilot:
1775
+ await pilot.pause()
1776
+ app.pq_models = {"half"} # picked by hand before
1777
+ app.push_screen(ResultsScreen())
1778
+ await pilot.pause(0.5)
1779
+ s = app.screen
1780
+ def state():
1781
+ pq = s.query_one("#per-question", DataTable)
1782
+ cols = [str(c.label) for c in pq.ordered_columns][len(s.PQ_FIXED):]
1783
+ return (sorted(app.pq_models), s.query_one("#pta-pack", Select).value,
1784
+ str(s.query_one("#pq-models", Button).label), cols, s.query_one("#pq-shared").value)
1785
+ s.query_one("#pq-pack", Select).value = "apps"
1786
+ await pilot.pause(0.3)
1787
+ print(state())
1788
+ s.query_one("#pta-pack", Select).value = "other"
1789
+ await pilot.pause(0.3)
1790
+ print(state())
1791
+ s.query_one("#pta-pack", Select).value = ""
1792
+ await pilot.pause(0.3)
1793
+ print(state())
1794
+ app.pq_models = set() # none picked: All packs gives every model again
1795
+ s.set_models(set())
1796
+ await pilot.pause(0.3)
1797
+ for pack in ("apps", ""):
1798
+ s.query_one("#pq-pack", Select).value = pack
1799
+ await pilot.pause(0.3)
1800
+ print(state())
1801
+ asyncio.run(go())
1802
+ """)
1803
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
1804
+ env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
1805
+ self.assertEqual(out.stdout.strip().splitlines(), [
1806
+ "(['full'], 'apps', 'Models: 1 complete on Apps', ['full'], False)",
1807
+ "(['full', 'solo'], 'other', 'Models: 2 complete on Other', ['full', 'solo'], False)",
1808
+ "(['half'], '', 'Models: 1 picked', ['half'], False)",
1809
+ "(['full'], 'apps', 'Models: 1 complete on Apps', ['full'], True)", # none picked before: ticked
1810
+ "([], '', 'Models: all', ['full', 'half', 'solo'], False)"], out.stderr[-2000:])
1811
+
1659
1812
  def test_cli_report_and_tui(self):
1660
1813
  with tempfile.TemporaryDirectory() as tmp:
1661
1814
  ws = os.path.join(tmp, "ws")
File without changes
File without changes