tuieval 0.2.0.dev74__tar.gz → 0.2.0.dev77__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/PKG-INFO +3 -1
  3. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/README.md +2 -0
  4. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/cli.py +1 -1
  6. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/compare.py +58 -1
  7. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/tui.py +7 -6
  8. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/tests/test_tuieval.py +73 -0
  9. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/.gitignore +0 -0
  10. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/LICENSE +0 -0
  11. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/RELEASING.md +0 -0
  12. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/README.md +0 -0
  13. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/favicon.ico +0 -0
  14. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/readme-header.png +0 -0
  15. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/social-preview.png +0 -0
  16. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-1024.png +0 -0
  17. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-128.png +0 -0
  18. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-16.png +0 -0
  19. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-16.svg +0 -0
  20. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-256.png +0 -0
  21. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-32.png +0 -0
  22. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-48.png +0 -0
  23. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-512.png +0 -0
  24. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-64.png +0 -0
  25. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  26. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/brand/tuieval-icon.svg +0 -0
  27. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/tui-answer-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/tui-run-reasoning.png +0 -0
  29. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/images/tui-setup.png +0 -0
  30. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/models.md +0 -0
  31. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/docs/writing-packs.md +0 -0
  32. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/pyproject.toml +0 -0
  33. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/__init__.py +0 -0
  34. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/__main__.py +0 -0
  35. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/builtin.toml +0 -0
  36. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/client.py +0 -0
  37. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/doctor.py +0 -0
  38. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/engine.py +0 -0
  39. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/logs.py +0 -0
  47. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/machines.py +0 -0
  48. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/onboard.py +0 -0
  49. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/packs.py +0 -0
  50. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/profiles.py +0 -0
  51. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/pta.py +0 -0
  52. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/remove.py +0 -0
  53. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/run_evals.py +0 -0
  54. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/scaffold.py +0 -0
  55. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/selftest.py +0 -0
  56. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/models.toml +0 -0
  57. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  58. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/answer/system.txt +0 -0
  59. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  60. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/code/pack.toml +0 -0
  61. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/code/system.txt +0 -0
  62. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  63. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  64. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/rag/system.txt +0 -0
  65. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  66. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  67. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/reply/system.txt +0 -0
  68. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  69. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  70. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  71. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  72. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  73. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/tune.py +0 -0
  74. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/verdict.py +0 -0
  75. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/watch_proxy.py +0 -0
  76. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/workspace.py +0 -0
  77. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/src/tuieval/yamlout.py +0 -0
  78. {tuieval-0.2.0.dev74 → tuieval-0.2.0.dev77}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `tuieval compare --reference <model>`: every other model paired against one on the questions both answered, per pack and overall: the difference with a 95% CI, better/tie/worse, critical failures and tokens vs the reference. `--packs` limits any compare to some packs.
6
+ - Fix: a question description or grader reason containing `[` no longer crashes the run screen.
5
7
  - Retest asks per pack before it starts: from zero, continue or leave out. It now really starts from zero, and replaced smoke results go to history/.
6
8
  - Retest: the setup checkbox "Rerun existing results" is now "Retest (ask again, even if already answered)", and `tuieval run --retest` is the same (`--force` still works).
7
9
  - `tuieval logs` follows the server log of the model running now and moves on with the run; `logs live` shows each answer as it finishes, `--list` every log.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev74
3
+ Version: 0.2.0.dev77
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
@@ -160,6 +160,7 @@ tuieval verdict # PASS / FAIL / INCONCLUSIVE per m
160
160
  tuieval report # the same with evidence, as a markdown file
161
161
  tuieval history # every verdict change over time
162
162
  tuieval compare --speed --pairwise --failures # scorecards
163
+ tuieval compare --reference <model> --packs X # each model vs one: better, tie or worse
163
164
  tuieval selftest # check every test's reference and wrong answers
164
165
  tuieval items # tests that don't separate models or look broken
165
166
  tuieval logs # follow the server log of the model running now
@@ -179,6 +180,7 @@ tuieval help
179
180
  - **Start with Production readiness.** A single critical failure already means FAIL, whatever the accuracy: a model that breaks a hard rule once in 300 answers will do it in production. The Failures tab and `tuieval report` list exactly which answers failed.
180
181
  - **INCONCLUSIVE is not "nearly passed".** It says what's missing (usually a Certify run, or more trials).
181
182
  - **Trust `tuieval compare --pairwise` over raw percentages.** Differences of one or two tests are usually noise.
183
+ - **Comparing variants of one model (fine-tunes, quantizations)?** `tuieval compare --reference <base>` pairs each against the base on the same questions and says better, tie or worse. Keep the server, quantization and settings the same, or they'll show up as differences too.
182
184
  - **Read `trunc` before accuracy.** A model that runs out of tokens isn't wrong; it's thinking too long for the budget.
183
185
  - **Watch TTFT for interactive use.** A model that is 5% more accurate but takes 3 s longer to start answering may be the worse choice.
184
186
 
@@ -134,6 +134,7 @@ tuieval verdict # PASS / FAIL / INCONCLUSIVE per m
134
134
  tuieval report # the same with evidence, as a markdown file
135
135
  tuieval history # every verdict change over time
136
136
  tuieval compare --speed --pairwise --failures # scorecards
137
+ tuieval compare --reference <model> --packs X # each model vs one: better, tie or worse
137
138
  tuieval selftest # check every test's reference and wrong answers
138
139
  tuieval items # tests that don't separate models or look broken
139
140
  tuieval logs # follow the server log of the model running now
@@ -153,6 +154,7 @@ tuieval help
153
154
  - **Start with Production readiness.** A single critical failure already means FAIL, whatever the accuracy: a model that breaks a hard rule once in 300 answers will do it in production. The Failures tab and `tuieval report` list exactly which answers failed.
154
155
  - **INCONCLUSIVE is not "nearly passed".** It says what's missing (usually a Certify run, or more trials).
155
156
  - **Trust `tuieval compare --pairwise` over raw percentages.** Differences of one or two tests are usually noise.
157
+ - **Comparing variants of one model (fine-tunes, quantizations)?** `tuieval compare --reference <base>` pairs each against the base on the same questions and says better, tie or worse. Keep the server, quantization and settings the same, or they'll show up as differences too.
156
158
  - **Read `trunc` before accuracy.** A model that runs out of tokens isn't wrong; it's thinking too long for the budget.
157
159
  - **Watch TTFT for interactive use.** A model that is 5% more accurate but takes 3 s longer to start answering may be the worse choice.
158
160
 
@@ -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.dev74'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev74')
21
+ __version__ = version = '0.2.0.dev77'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev77')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -13,7 +13,7 @@ Start here
13
13
  tuieval verdict which model is ready for what, and what to run next
14
14
 
15
15
  Day to day
16
- tuieval compare [...] scorecards (--speed, --pairwise, --failures)
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
18
  tuieval report the verdicts with their evidence, as a markdown file
19
19
  tuieval history every verdict change, newest first
@@ -5,6 +5,8 @@ Usage:
5
5
  tuieval compare --speed # speed & tokens table (TTFT, tok/s, tokens, memory)
6
6
  tuieval compare --machine m3max-64gb # speed on another machine (measured there, or projected)
7
7
  tuieval compare --pairwise --failures # is each difference real? which tests failed?
8
+ tuieval compare --reference MODEL # every model paired against one: better, tie or worse
9
+ tuieval compare --packs coding,rag # only these packs (works with every option)
8
10
  tuieval compare results/some-model/*.json # specific files
9
11
  """
10
12
  import collections
@@ -409,6 +411,49 @@ def pairwise(rows, rng=None):
409
411
  return out
410
412
 
411
413
 
414
+ def vs_reference(rows, ref, rng=None):
415
+ """Every other model against one reference model, paired on the questions both answered:
416
+ [{model, pack ("all" = every pack together), n, diff, lo, hi, verdict, crit, ref_crit, tokens}].
417
+
418
+ diff is the mean over shared questions of (pass rate - the reference's pass rate), with a 95%
419
+ bootstrap CI; verdict is "better" or "worse" when the CI excludes 0, else "tie". crit/ref_crit
420
+ count critical failures on those questions; tokens is the median of per-question token ratios
421
+ (median tokens over repeats, model / reference), so 0.5 means half the tokens."""
422
+ rng = rng or random.Random(0)
423
+ by = collections.defaultdict(lambda: collections.defaultdict(list)) # model -> test -> rows
424
+ suite = {}
425
+ for r in rows:
426
+ by[r["model"]][r["test"]].append(r)
427
+ suite[r["test"]] = r["suite"]
428
+ if ref not in by:
429
+ return []
430
+ out = []
431
+ for m in sorted(set(by) - {ref}):
432
+ shared = sorted(set(by[m]) & set(by[ref]))
433
+ for pack in sorted({suite[t] for t in shared}) + ["all"]:
434
+ tests = [t for t in shared if pack in ("all", suite[t])]
435
+ if not tests:
436
+ continue
437
+ rate = lambda model, t: statistics.fmean(r["ok"] for r in by[model][t])
438
+ diffs = [rate(m, t) - rate(ref, t) for t in tests]
439
+ lo, hi = bootstrap_ci(diffs, rng)
440
+ crit = lambda model: sum(r.get("severity") == "critical" for t in tests for r in by[model][t])
441
+ ratios = [a / b for t in tests
442
+ if (a := median(r["tokens"] for r in by[m][t])) and (b := median(r["tokens"] for r in by[ref][t]))]
443
+ out.append({"model": m, "pack": pack, "n": len(tests), "diff": statistics.fmean(diffs), "lo": lo, "hi": hi,
444
+ "verdict": "better" if lo > 0 else "worse" if hi < 0 else "tie",
445
+ "crit": crit(m), "ref_crit": crit(ref), "tokens": median(ratios)})
446
+ return out
447
+
448
+
449
+ def vs_reference_table(results, ref):
450
+ header = ["model", "pack", "questions", f"vs {ref}", "95% CI", "verdict", "critical (vs ref)", "tokens vs ref"]
451
+ table = [[x["model"], x["pack"], x["n"], f"{100 * x['diff']:+.1f} pts", f"{100 * x['lo']:+.1f} to {100 * x['hi']:+.1f}",
452
+ x["verdict"].upper() if x["pack"] == "all" else x["verdict"], f"{x['crit']} ({x['ref_crit']})",
453
+ _fmt(x["tokens"], ".2f") + ("×" if x["tokens"] is not None else "")] for x in results]
454
+ return header, table
455
+
456
+
412
457
  def failures(rows):
413
458
  return [(r["model"], r.get("name", r["test"]), ("TRUNCATED " if r["truncated"] and not r["reason"].startswith("TRUNCATED")
414
459
  else "") + r["reason"], r.get("difficulty", "unrated"))
@@ -499,10 +544,22 @@ def main(argv):
499
544
  flags = {a for a in argv if a.startswith("--")}
500
545
  log_dir = argv[argv.index("--logs") + 1] if "--logs" in argv else None
501
546
  machine_arg = argv[argv.index("--machine") + 1] if "--machine" in argv else None
502
- paths = [a for a in argv if not a.startswith("--") and a not in (log_dir, machine_arg)] or default_paths()
547
+ ref = argv[argv.index("--reference") + 1] if "--reference" in argv else None
548
+ only_packs = argv[argv.index("--packs") + 1].split(",") if "--packs" in argv else None
549
+ values = (log_dir, machine_arg, ref, ",".join(only_packs) if only_packs else None)
550
+ paths = [a for a in argv if not a.startswith("--") and a not in values] or default_paths()
551
+ if only_packs:
552
+ paths = [p for p in paths if os.path.basename(p)[:-len(".json")] in only_packs]
503
553
  if not paths:
504
554
  sys.exit(__doc__ + "\n(no results found in results/<model>/<pack>.json: run some evals first)")
505
555
  rows, infos = load(paths)
556
+ if ref:
557
+ if ref not in {r["model"] for r in rows}:
558
+ sys.exit(f"no results for reference model {ref!r}" + (f" in {', '.join(only_packs)}" if only_packs else ""))
559
+ print(f"Paired against {ref} on the questions both answered (pass rate per question, repeats averaged;"
560
+ "\n95% bootstrap CI over questions; BETTER/WORSE only when the CI excludes 0):")
561
+ print_table(*vs_reference_table(vs_reference(rows, ref, random.Random(0)), ref))
562
+ return
506
563
  rng = random.Random(0)
507
564
  from . import packs as packs_mod
508
565
  packs_mod.load_packs(errors=[]) # loads the workspace's graders, and their Scorecard columns
@@ -2239,9 +2239,10 @@ class RunScreen(Screen):
2239
2239
  self.req_started, self.req_tokens, self.req_first = time.time(), 0, None
2240
2240
  job = d["job"]
2241
2241
  rep_note = f" · repeat {d['repeat'] + 1}/{job.repeat}" if job.repeat > 1 else ""
2242
- self.current = (f"[b]{job.label}[/b] · {job.pack.label} · test {job.done + 1}/{job.total}{rep_note}"
2242
+ self.current = (f"[b]{rich_escape(job.label)}[/b] · {rich_escape(job.pack.label)} · test "
2243
+ f"{job.done + 1}/{job.total}{rep_note}"
2243
2244
  f"{' [magenta]🖼 image[/magenta]' if d['has_image'] else ''}\n"
2244
- f"{diff_badge(d.get('difficulty', 'unrated'))} {d['test']}")
2245
+ f"{diff_badge(d.get('difficulty', 'unrated'))} {rich_escape(d['test'])}")
2245
2246
  elif kind == "request_done":
2246
2247
  if self.watched(kind, d):
2247
2248
  self.flush_streams()
@@ -2251,12 +2252,12 @@ class RunScreen(Screen):
2251
2252
  # follow new rows unless you're browsing an older one
2252
2253
  at_end = not recent.has_focus or recent.cursor_row >= recent.row_count - 1
2253
2254
  calls = f" [cyan]→ {r['tool_calls'][0]['name']}()[/cyan]" if r.get("tool_calls") else ""
2254
- row_key = recent.add_row(mark, diff_badge(r.get("difficulty", "unrated")), r["description"][:50], job.label,
2255
+ row_key = recent.add_row(mark, diff_badge(r.get("difficulty", "unrated")), rich_escape(r["description"][:50]), job.label,
2255
2256
  str(r.get("completion_tokens") or ""),
2256
2257
  (f"{r['ttft_s']:.1f}" + (" ⟲" if r.get("cached_tokens") else ""))
2257
2258
  if r.get("ttft_s") is not None else "",
2258
2259
  f"{r['gen_tps']:.0f}" if r.get("gen_tps") else "", f"{r['total_s']:.0f}",
2259
- r["reason"][:90] + calls
2260
+ rich_escape(r["reason"][:90]) + calls
2260
2261
  + (f" [red]copy of repeat {r['identical_to_repeat'] + 1}[/red]"
2261
2262
  if r.get("identical_to_repeat") is not None else ""))
2262
2263
  self.stats.append(r)
@@ -2364,7 +2365,7 @@ class RunScreen(Screen):
2364
2365
  self.event("[bold yellow]REUSE[/bold yellow] " + msg)
2365
2366
  self.notify(msg, severity="warning", timeout=20)
2366
2367
  elif kind == "identical_answer":
2367
- msg = (f"{d['label']} · {d['pack']} · {d['test']}: repeat {d['repeat'] + 1} is an exact copy of repeat "
2368
+ msg = (f"{d['label']} · {d['pack']} · {rich_escape(d['test'])}: repeat {d['repeat'] + 1} is an exact copy of repeat "
2368
2369
  f"{d['same'] + 1}, so the server may be returning cached answers rather than fresh ones.")
2369
2370
  self.event("[bold red]COPY[/bold red] " + msg)
2370
2371
  self.notify(msg, severity="error", timeout=20)
@@ -2711,7 +2712,7 @@ class ResultsScreen(Screen):
2711
2712
  # compare.failures lists the failed rows in order, so they line up with fail_rows
2712
2713
  self.fail_rows = [r for r in rows if not r["ok"]]
2713
2714
  for i, (model, test, reason, level) in enumerate(compare.failures(rows)):
2714
- f.add_row(model, diff_badge(level), test, rich_escape(reason[:130]), key=str(i))
2715
+ f.add_row(model, diff_badge(level), rich_escape(test), rich_escape(reason[:130]), key=str(i))
2715
2716
  self.fill_per_question()
2716
2717
  self.fill_pta()
2717
2718
  self.fill_readiness(results_dir)
@@ -209,6 +209,29 @@ class Units(unittest.TestCase):
209
209
  self.assertEqual(compare.settings_notes(infos),
210
210
  ["Settings differ: effort medium on 4 models; penalty 1.5 on j."])
211
211
 
212
+ def test_vs_reference_pairs_on_shared_questions(self):
213
+ from tuieval import compare
214
+ row = lambda m, pack, t, ok, tokens=100, sev=None, rep=0: {
215
+ "model": m, "suite": pack, "test": f"{pack}: {t}", "ok": ok, "tokens": tokens, "severity": sev, "repeat": rep}
216
+ rows = [row("ref", "a", f"q{i}", i < 10) for i in range(20)] # ref: 10/20 on a
217
+ rows += [row("good", "a", f"q{i}", True, tokens=50) for i in range(20)] # good: 20/20, half the tokens
218
+ rows += [row("same", "a", f"q{i}", i < 10) for i in range(20)]
219
+ rows += [row("bad", "a", f"q{i}", False, sev="critical" if i == 0 else None) for i in range(20)]
220
+ rows += [row("ref", "b", "x", True), row("good", "b", "x", False), row("good", "b", "x", True, rep=1)]
221
+ rows += [row("good", "b", "only-good", True)] # not shared: ignored
222
+ res = {(x["model"], x["pack"]): x for x in compare.vs_reference(rows, "ref")}
223
+ self.assertEqual(res["good", "a"]["verdict"], "better")
224
+ self.assertAlmostEqual(res["good", "a"]["diff"], 0.5)
225
+ self.assertEqual(res["good", "a"]["tokens"], 0.5)
226
+ self.assertEqual(res["same", "a"]["verdict"], "tie")
227
+ self.assertEqual((res["bad", "a"]["verdict"], res["bad", "a"]["crit"], res["bad", "a"]["ref_crit"]), ("worse", 1, 0))
228
+ self.assertEqual(res["good", "b"]["n"], 1)
229
+ self.assertAlmostEqual(res["good", "b"]["diff"], -0.5) # repeats averaged
230
+ self.assertEqual(res["good", "all"]["n"], 21)
231
+ self.assertEqual(compare.vs_reference(rows, "nobody"), [])
232
+ header, table = compare.vs_reference_table([res["good", "all"]], "ref")
233
+ self.assertEqual(table[0][5], "BETTER")
234
+
212
235
  def test_eta_goes_pack_by_pack(self):
213
236
  from tuieval.tui import RunScreen
214
237
  job = lambda key, label, total, done=0, status="waiting": types.SimpleNamespace(
@@ -735,6 +758,56 @@ class Parallel(unittest.TestCase):
735
758
  self.assertEqual(sum(r["pass"] for r in self.results("b")["results"]), 6)
736
759
 
737
760
 
761
+ class MarkupInText(unittest.TestCase):
762
+ """Brackets in a description or a grader's reason are shown as text: an unmatched
763
+ "[" once crashed the run screen (it swallowed the "[dim]" of the line after it)."""
764
+
765
+ def test_run_screen_shows_brackets(self):
766
+ with tempfile.TemporaryDirectory() as tmp:
767
+ ws = os.path.join(tmp, "ws")
768
+ tuieval(tmp, "init", ws)
769
+ d = os.path.join(ws, "packs", "br")
770
+ os.makedirs(d)
771
+ write(os.path.join(d, "pack.toml"), 'label = "Brackets"\n[certify]\nrepeat = 1\n[gate]\nmin_accuracy = 0.1\n')
772
+ write(os.path.join(d, "tests.yaml"),
773
+ "- {id: a, description: 'notional in [t - 10 s, t), excluding ties', input: q a, expected: 1, "
774
+ "reference: 'ANSWER: 1', wrong: ['ANSWER: [2'], difficulty: hard}\n"
775
+ "- {id: b, description: 'window [a, b) then [/dim] text', input: q b, expected: 1, "
776
+ "reference: 'ANSWER: 1', wrong: ['ANSWER: [2'], difficulty: hard}\n")
777
+ mock = [sys.executable, os.path.join(HERE, "mock_server.py"), "--port", "{port}", "--packs",
778
+ os.path.join(ws, "packs"), "--model", "{served_name}", "--mode", "wrong", "--delay", "0.2"]
779
+ with open(os.path.join(ws, "models.toml"), "a") as f:
780
+ f.write(f'\n[servers.mk]\ncmd = {json.dumps(mock)}\nport = {free_port()}\n'
781
+ '\n[[models]]\nlabel = "m"\nserver = "mk"\nmodel = "m"\n')
782
+ code = textwrap.dedent("""
783
+ import asyncio, json, time
784
+ from tuieval.tui import EvalsApp, RunScreen
785
+ from textual.widgets import SelectionList
786
+ async def go():
787
+ app = EvalsApp({})
788
+ async with app.run_test(size=(160, 50)) as pilot:
789
+ await pilot.pause()
790
+ s = app.screen
791
+ s.query_one("#suites", SelectionList).select("br")
792
+ s.selected_models = {"m"}
793
+ s.refresh_models()
794
+ s.query_one("#tier-certify").value = True
795
+ await pilot.pause()
796
+ s.action_start()
797
+ deadline = time.time() + 240
798
+ while time.time() < deadline:
799
+ await pilot.pause(0.2)
800
+ if isinstance(app.screen, RunScreen) and not app.screen.running:
801
+ break
802
+ print(json.dumps([(j.key, j.status, j.done) for j in app.screen.jobs]))
803
+ asyncio.run(go())
804
+ """)
805
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=480,
806
+ env=dict(os.environ, TUIEVAL_HOME=ws, TUIEVAL_DETECT_PORTS=""))
807
+ self.assertNotIn("MarkupError", p.stderr + p.stdout)
808
+ self.assertEqual(json.loads(p.stdout.strip().splitlines()[-1]), [["m/br", "done", 2]], p.stderr[-2000:])
809
+
810
+
738
811
  class AddToRun(unittest.TestCase):
739
812
  """More evals while a run is going: added to it (they run after its other jobs) or queued after it."""
740
813
 
File without changes
File without changes