tuieval 0.2.0.dev77__py3-none-any.whl → 0.2.0.dev81__py3-none-any.whl

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/_version.py CHANGED
@@ -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.dev77'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev77')
21
+ __version__ = version = '0.2.0.dev81'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev81')
23
23
 
24
24
  __commit_id__ = commit_id = None
tuieval/engine.py CHANGED
@@ -28,6 +28,7 @@ interrupted run resumes from its .partial.jsonl, request by request.
28
28
  Events are delivered as on_event(kind, **data), from the engine thread.
29
29
  """
30
30
  import base64
31
+ import collections
31
32
  import contextlib
32
33
  import dataclasses
33
34
  import fcntl
@@ -46,6 +47,7 @@ import subprocess
46
47
  import threading
47
48
  import time
48
49
  import tomllib
50
+ import urllib.parse
49
51
  import urllib.request
50
52
 
51
53
  from . import client
@@ -438,6 +440,8 @@ class Job:
438
440
  finished: float = 0.0
439
441
  resumed: int = 0 # answers already done when this sitting started
440
442
  earlier: list = dataclasses.field(default_factory=list) # earlier sittings (see Engine._sittings)
443
+ stop_early: bool = False # stop once the pack's FAIL is certain (verdict.fail_certain)
444
+ first: list = dataclasses.field(default_factory=list) # test ids round 1 asks first (most failed)
441
445
 
442
446
  @property
443
447
  def label(self):
@@ -700,6 +704,7 @@ class Engine:
700
704
  self._procs = []
701
705
  self._stop_lock = threading.Lock()
702
706
  self._run_lock = threading.Lock()
707
+ self._machine_take = threading.Lock() # one lane takes the machine lock for a hosted-only run
703
708
  self._run = None # the run going: {"jobs", "pending", "open", "machine", "cond"} (add_jobs)
704
709
  self._header_cache = {}
705
710
  self._serving = {}
@@ -1152,14 +1157,15 @@ class Engine:
1152
1157
  return not current and header_matches(head, pack.fingerprint, settings)
1153
1158
 
1154
1159
  # ---- planning
1155
- def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None):
1160
+ def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None, stop_early=None):
1156
1161
  """Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
1157
1162
  tests). repeat: times each question is asked; None = the tier default (1 for smoke and
1158
1163
  screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
1159
1164
  those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
1160
1165
  matching results are kept and only missing answers are run, unless force; force with
1161
1166
  picked tests reruns those and keeps the pack's other answers. force is True, False, or the
1162
- keys ("label/pack") of the jobs to retest, the others resuming."""
1167
+ keys ("label/pack") of the jobs to retest, the others resuming. stop_early: stop each pack
1168
+ once its FAIL is certain, asking first what models fail most; None = each pack's stop_early."""
1163
1169
  if tier not in TIERS:
1164
1170
  raise ValueError(f"tier must be one of {TIERS}")
1165
1171
  jobs = []
@@ -1173,7 +1179,10 @@ class Engine:
1173
1179
  job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
1174
1180
  self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
1175
1181
  merge=not retest or bool(picked), redo=retest and bool(picked), fresh=retest,
1176
- settings=self.settings(m))
1182
+ settings=self.settings(m),
1183
+ stop_early=pack.stop_early if stop_early is None else bool(stop_early))
1184
+ if job.stop_early:
1185
+ job.first = self.most_failed_first(pack)
1177
1186
  sv = self.serving(m)
1178
1187
  status = self.result_status(label, name, tier)
1179
1188
  job.discards = self._discards(job)
@@ -1251,6 +1260,36 @@ class Engine:
1251
1260
  except (TypeError, ValueError):
1252
1261
  return 1
1253
1262
 
1263
+ def parallel_hosted(self):
1264
+ """How many hosted models (APIs) a run serves at a time, besides the local ones: models.toml
1265
+ [machines.<id>] parallel_hosted (default 4). They don't use this machine's GPU or memory."""
1266
+ try:
1267
+ return max(1, int(self.machine_settings().get("parallel_hosted", 4)))
1268
+ except (TypeError, ValueError):
1269
+ return 4
1270
+
1271
+ def models_at_once(self, labels, parallel=None, parallel_hosted=None):
1272
+ """How many of these models run at the same time: local ones up to parallel (parallel_models),
1273
+ hosted ones beside them up to parallel_hosted."""
1274
+ labels = set(labels)
1275
+ hosted = sum(self.hosted(self.model(l)) for l in labels)
1276
+ return (min(parallel or self.parallel_models(), len(labels) - hosted)
1277
+ + min(parallel_hosted or self.parallel_hosted(), hosted))
1278
+
1279
+ def hosted(self, m):
1280
+ """A model served by an API elsewhere: tuieval doesn't start its server and its url isn't this
1281
+ machine (or its server sets hosted = true). A server you started yourself on localhost still
1282
+ counts as local (it uses the GPU)."""
1283
+ if m.get("remote"):
1284
+ return True
1285
+ server = self.cfg["servers"][m["server"]]
1286
+ if "hosted" in server: # say so for a server the url doesn't give away (e.g. a local proxy to an API)
1287
+ return bool(server["hosted"])
1288
+ if server.get("cmd"):
1289
+ return False
1290
+ host = urllib.parse.urlparse(server.get("url") or "").hostname or ""
1291
+ return bool(host) and host not in ("localhost", "127.0.0.1", "::1", "0.0.0.0")
1292
+
1254
1293
  def memory_need_gb(self, m):
1255
1294
  """GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
1256
1295
  server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
@@ -1609,17 +1648,17 @@ class Engine:
1609
1648
  return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
1610
1649
 
1611
1650
  # ---- running
1612
- def run(self, jobs, parallel=None):
1651
+ def run(self, jobs, parallel=None, parallel_hosted=None):
1613
1652
  """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1614
- models are served at a time (default: parallel_models for this machine). add_jobs() can add
1615
- more while it runs; they run after the ones before them."""
1653
+ local models are served at a time (default: parallel_models for this machine); hosted models
1654
+ (APIs) run beside them, up to parallel_hosted at a time (default: parallel_hosted). add_jobs()
1655
+ can add more while it runs; they run after the ones before them."""
1616
1656
  self._cancel.clear()
1617
1657
  self.emit("queue_started", jobs=jobs)
1618
1658
  models = sorted({j.label for j in jobs if j.status == "waiting"})
1619
1659
  machine = bool(jobs) and self.needs_machine(jobs)
1620
- lock = (self.machine_lock(f"{jobs[0].tier} run: {', '.join(models[:3])}"
1621
- + (f" +{len(models) - 3}" if len(models) > 3 else ""))
1622
- if machine else contextlib.nullcontext())
1660
+ what = f"{jobs[0].tier} run: {', '.join(models[:3])}" + (f" +{len(models) - 3}" if len(models) > 3 else "")
1661
+ stack = contextlib.ExitStack() # holds the machine lock; a hosted-only run takes it if local models are added
1623
1662
  order = []
1624
1663
  for j in jobs:
1625
1664
  if j.label not in order:
@@ -1627,10 +1666,13 @@ class Engine:
1627
1666
  groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1628
1667
  with self._run_lock:
1629
1668
  self._run = {"jobs": jobs, "pending": [g for g in groups if g[1]], "open": True, "machine": machine,
1630
- "cond": threading.Condition(self._run_lock)}
1669
+ "cond": threading.Condition(self._run_lock), "stack": stack, "what": what}
1631
1670
  try:
1632
- with lock:
1633
- self._run_lanes(max(1, int(parallel or self.parallel_models())))
1671
+ with stack:
1672
+ if machine:
1673
+ stack.enter_context(self.machine_lock(what))
1674
+ self._run_lanes(max(1, int(parallel or self.parallel_models())),
1675
+ max(1, int(parallel_hosted or self.parallel_hosted())))
1634
1676
  if self._cancel.is_set():
1635
1677
  raise Cancelled()
1636
1678
  except Cancelled:
@@ -1657,14 +1699,13 @@ class Engine:
1657
1699
  def add_jobs(self, new):
1658
1700
  """Add planned jobs to the run going on, after its other jobs. Returns (added, why not): a
1659
1701
  pack already waiting or running in it isn't added twice, and nothing is added once the run is
1660
- finishing or cancelled, or when it holds no machine lock (hosted only) and these need one.
1702
+ finishing or cancelled. Local models added to a hosted-only run take the machine lock when the
1703
+ first of them starts.
1661
1704
  Runs on the caller's thread, so the caller shows what was added (no event)."""
1662
1705
  with self._run_lock:
1663
1706
  r = self._run
1664
1707
  if not r or not r["open"] or self._cancel.is_set():
1665
1708
  return [], "the run is finishing"
1666
- if not r["machine"] and self.needs_machine(new):
1667
- return [], "the run going uses only hosted models, and these need this machine"
1668
1709
  busy = {j.key for j in r["jobs"] if j.status in ("waiting", "loading", "running")}
1669
1710
  added = [j for j in new if j.status == "waiting" and j.key not in busy]
1670
1711
  r["jobs"].extend(added)
@@ -1677,33 +1718,51 @@ class Engine:
1677
1718
  r["cond"].notify_all()
1678
1719
  return added, None if added else "everything picked is already in the run"
1679
1720
 
1680
- def _run_lanes(self, parallel):
1681
- """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1682
- model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1683
- next to the ones running (memory_need_gb; an unknown need is left to the setting). Lanes
1684
- wait for jobs added meanwhile (add_jobs) until every model is done."""
1721
+ def _run_lanes(self, parallel, parallel_hosted=4):
1722
+ """Run each model's jobs in its own lane: up to `parallel` local models at a time, in order (the
1723
+ next one starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1724
+ next to the ones running (memory_need_gb; an unknown need is left to the setting)), and beside
1725
+ them up to `parallel_hosted` hosted models, which need none of this machine. Lanes wait for jobs
1726
+ added meanwhile (add_jobs) until every model is done."""
1685
1727
  pending, running, told = self._run["pending"], {}, set()
1686
1728
  cond = self._run["cond"]
1729
+ hosted = {}
1730
+
1731
+ def is_hosted(label):
1732
+ if label not in hosted:
1733
+ hosted[label] = self.hosted(self.model(label))
1734
+ return hosted[label]
1687
1735
 
1688
1736
  def blocked(label):
1689
1737
  """Why the next model can't start now (None: it can)."""
1690
- if not running:
1738
+ if is_hosted(label):
1739
+ return "" if sum(map(is_hosted, running)) >= parallel_hosted else None
1740
+ local = [l for l in running if not is_hosted(l)]
1741
+ if not local:
1691
1742
  return None
1692
- if len(running) >= parallel:
1743
+ if len(local) >= parallel:
1693
1744
  return ""
1694
1745
  m = self.model(label)
1695
- if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
1696
- alone = label if self.runs_alone(m) else next(l for l in running if self.runs_alone(self.model(l)))
1746
+ if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in local):
1747
+ alone = label if self.runs_alone(m) else next(l for l in local if self.runs_alone(self.model(l)))
1697
1748
  return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
1698
1749
  "on its own")
1699
1750
  need = self.memory_need_gb(m)
1700
- used = sum(n for n in running.values() if n)
1751
+ used = sum(running[l] or 0 for l in local)
1701
1752
  room = self.memory_available_gb()
1702
1753
  if need and used + need > room:
1703
- return (f"needs ~{need:.0f} GB; {', '.join(running)} take ~{used:.0f} of {room:.0f} GB, so it "
1754
+ return (f"needs ~{need:.0f} GB; {', '.join(local)} take ~{used:.0f} of {room:.0f} GB, so it "
1704
1755
  "starts when there's room")
1705
1756
  return None
1706
1757
 
1758
+ def take_machine():
1759
+ """A hosted-only run that was given local models takes the machine lock before the first
1760
+ of them starts (it may wait for another tuieval window, like any run)."""
1761
+ with self._machine_take:
1762
+ if not self._run["machine"]:
1763
+ self._run["stack"].enter_context(self.machine_lock(self._run["what"]))
1764
+ self._run["machine"] = True
1765
+
1707
1766
  def lane():
1708
1767
  while True:
1709
1768
  mine, note = None, None
@@ -1713,33 +1772,41 @@ class Engine:
1713
1772
  cond.notify_all()
1714
1773
  return
1715
1774
  # in order, except that a model added again while it runs waits for itself to end
1716
- # without holding up the ones behind it
1717
- nxt = next((i for i, g in enumerate(pending) if g[0] not in running), None)
1718
- label = pending[nxt][0] if nxt is not None else None
1719
- why = blocked(label) if label else "" # none can start: a model still runs, more may come
1720
- if why is None:
1721
- label, mine = pending.pop(nxt)
1722
- running[label] = self.memory_need_gb(self.model(label))
1723
- elif why and label not in told:
1724
- told.add(label)
1725
- note = f"{label} waits: {why}"
1726
- else:
1727
- cond.wait(1.0)
1775
+ # without holding up the ones behind it; the first waiting hosted model doesn't wait
1776
+ # for local ones (and the first local one doesn't wait for hosted ones)
1777
+ waiting = [i for i, g in enumerate(pending) if g[0] not in running]
1778
+ first = {}
1779
+ for i in waiting:
1780
+ first.setdefault(is_hosted(pending[i][0]), i)
1781
+ why, label = "", None
1782
+ for i in sorted(first.values()):
1783
+ why = blocked(pending[i][0])
1784
+ if why is None:
1785
+ label, mine = pending.pop(i)
1786
+ running[label] = self.memory_need_gb(self.model(label))
1787
+ break
1788
+ if mine is None:
1789
+ label = pending[min(first.values())][0] if first else None
1790
+ if why and label and label not in told:
1791
+ told.add(label)
1792
+ note = f"{label} waits: {why}"
1793
+ else:
1794
+ cond.wait(1.0)
1728
1795
  if note:
1729
1796
  self.emit("model_waiting", label=label, message=note)
1730
1797
  if mine is None:
1731
1798
  continue
1732
1799
  try:
1800
+ if not is_hosted(label) and self.cfg["servers"][mine[0].model["server"]].get("cmd"):
1801
+ take_machine()
1733
1802
  self._run_lane(label, mine)
1734
1803
  finally:
1735
1804
  with cond:
1736
1805
  running.pop(label, None)
1737
1806
  cond.notify_all()
1738
1807
 
1739
- if parallel == 1:
1740
- lane()
1741
- return
1742
- threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel)]
1808
+ # lanes for hosted models too, even if none is queued yet: add_jobs may bring some
1809
+ threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel + parallel_hosted)]
1743
1810
  for t in threads:
1744
1811
  t.start()
1745
1812
  for t in threads:
@@ -2005,13 +2072,30 @@ class Engine:
2005
2072
 
2006
2073
  @staticmethod
2007
2074
  def round_order(job, rep):
2008
- """The tests in the order round `rep` asks them: the pack's order first, then a fixed
2009
- shuffle per round (the same on every run, so resuming and reruns ask in the same order)."""
2075
+ """The tests in the order round `rep` asks them: the pack's order first (with an early stop,
2076
+ the most failed first), then a fixed shuffle per round (the same on every run, so resuming
2077
+ and reruns ask in the same order)."""
2010
2078
  tests = list(job.tests)
2011
2079
  if rep:
2012
2080
  random.Random(f"{job.pack.name}:{rep}").shuffle(tests)
2081
+ elif job.first:
2082
+ rank = {tid: i for i, tid in enumerate(job.first)}
2083
+ tests.sort(key=lambda t: rank.get(t["id"], len(rank)))
2013
2084
  return tests
2014
2085
 
2086
+ def most_failed_first(self, pack):
2087
+ """The pack's test ids, the ones models failed most often first (critical failures count
2088
+ double), from every model's stored results; ties keep the pack's order."""
2089
+ fails, seen = collections.Counter(), collections.Counter()
2090
+ for m in self.cfg["models"]:
2091
+ for r in self.records(m["label"], pack.name):
2092
+ if client.server_error_row(r):
2093
+ continue
2094
+ seen[r["test"]] += 1
2095
+ fails[r["test"]] += (not r["pass"]) + (r.get("severity") == "critical")
2096
+ order = {t["id"]: i for i, t in enumerate(pack.tests)}
2097
+ return sorted(order, key=lambda tid: (-(fails[tid] / seen[tid] if seen[tid] else 0), order[tid]))
2098
+
2015
2099
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
2016
2100
  SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "vram_peak_gb", "kernel_share_max", "machine",
2017
2101
  "loaded_alongside")
@@ -2143,7 +2227,10 @@ class Engine:
2143
2227
  # a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
2144
2228
  tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
2145
2229
  warned_reuse = False
2230
+ stopped = self._stop_reason(job, mine)
2146
2231
  for rep in range(job.repeat):
2232
+ if stopped:
2233
+ break
2147
2234
  for test in self.round_order(job, rep):
2148
2235
  if (test["id"], rep) in done_keys:
2149
2236
  continue
@@ -2237,6 +2324,10 @@ class Engine:
2237
2324
  job.passed += bool(g["pass"])
2238
2325
  job.failed += not g["pass"]
2239
2326
  self.emit("request_done", job=job, record=rec)
2327
+ mine.append(rec)
2328
+ stopped = self._stop_reason(job, mine)
2329
+ if stopped:
2330
+ break
2240
2331
  job.status, job.finished = "done", time.time()
2241
2332
  merged = {(r["test"], r["repeat"]): r for r in kept}
2242
2333
  merged.update({(r["test"], r["repeat"]): r for r in new})
@@ -2246,9 +2337,17 @@ class Engine:
2246
2337
  os.remove(partial)
2247
2338
  with contextlib.suppress(OSError):
2248
2339
  os.remove(self._sittings_path(job))
2249
- job.note = f"{job.passed}/{job.done} passed"
2340
+ job.note = f"{job.passed}/{job.done} passed" + (f"; stopped early, FAIL is certain: {stopped}" if stopped else "")
2250
2341
  self.emit("job_done", job=job)
2251
2342
 
2343
+ def _stop_reason(self, job, rows):
2344
+ """Why the job stops early now (verdict.fail_certain), or None."""
2345
+ if not job.stop_early:
2346
+ return None
2347
+ from . import verdict
2348
+ return verdict.fail_certain(job.pack, [r for r in rows if not client.server_error_row(r)],
2349
+ self.cfg["defaults"]["repeat"])
2350
+
2252
2351
  def _partial_to_history(self, job, keep=False):
2253
2352
  """Keep an unfinished run that is set aside in history/; keep: copy it (some of its answers stay)."""
2254
2353
  partial = job.out_path + ".partial.jsonl"
tuieval/packs.py CHANGED
@@ -49,6 +49,7 @@ class Pack:
49
49
  gate: dict # release criteria (see verdict.py)
50
50
  screen: int # tests in a screening run
51
51
  certify_repeat: int # repeats in a certification run (0 = models.toml default)
52
+ stop_early: bool = False # stop the pack once its FAIL is certain (pack.toml stop_early)
52
53
 
53
54
  @property
54
55
  def modules(self):
@@ -221,7 +222,8 @@ def load_pack(path):
221
222
  system=system, needs=list(needs), tools=tools, tests=tests,
222
223
  fingerprint=_fingerprint(system, tools, meta.get("grader", "answer"), tests, images),
223
224
  gate=gate, screen=int(meta.get("screen", 10)),
224
- certify_repeat=int(meta.get("certify", {}).get("repeat", 0)))
225
+ certify_repeat=int(meta.get("certify", {}).get("repeat", 0)),
226
+ stop_early=bool(meta.get("stop_early", False)))
225
227
 
226
228
 
227
229
  def load_packs(packs_dir=None, errors=None):
tuieval/run_evals.py CHANGED
@@ -17,6 +17,7 @@
17
17
  tuieval run --tier smoke --only openrouter:qwen/qwen3-32b # any OpenRouter model, no models.toml edit
18
18
  tuieval run --dry-run # print the plan and server commands only
19
19
  tuieval run --parallel 2 # serve two models at a time (parallel_models in models.toml)
20
+ tuieval run --parallel-hosted 6 # hosted models at a time beside them (default 4)
20
21
  tuieval add ~/models/New-Model-Q4_K_M.gguf # register a model (--vision/--mmproj, --no-think, --tags)
21
22
  tuieval scan # GGUFs in model_dirs that aren't registered yet
22
23
  tuieval list # the models tuieval knows; hidden ones listed separately
@@ -745,6 +746,9 @@ def main(argv=None):
745
746
  "pack in --packs, else pack:id (and --packs defaults to those packs)")
746
747
  p.add_argument("--retest", "--force", action="store_true", dest="force",
747
748
  help="ask again even if already answered: the packs, or just the --tests (earlier answers go to history/)")
749
+ p.add_argument("--stop-early", action="store_true",
750
+ help="stop each pack once its FAIL is certain (a critical failure, or a bar out of reach), "
751
+ "asking first what models fail most (default: each pack's stop_early)")
748
752
  p.add_argument("--repeat", type=int, help="times each question is asked, any tier (default: 1 for smoke and "
749
753
  "screen, the pack's certification count for certify)")
750
754
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
@@ -752,6 +756,9 @@ def main(argv=None):
752
756
  p.add_argument("--smoke", action="store_const", const="smoke", dest="tier", help="same as --tier smoke")
753
757
  p.add_argument("--parallel", type=int, metavar="N",
754
758
  help="models served at a time (default: parallel_models for this machine in models.toml, else 1)")
759
+ p.add_argument("--parallel-hosted", type=int, metavar="N",
760
+ help="hosted models (APIs) served at a time beside the local ones (default: parallel_hosted "
761
+ "in models.toml, else 4)")
755
762
  p.add_argument("--dry-run", action="store_true", help="print the plan and commands without running")
756
763
  p.add_argument("--brief", action="store_true", help="end with one line per model instead of the scorecards")
757
764
  p.add_argument("--models", default=None, help="default: the workspace's models.toml")
@@ -766,6 +773,9 @@ def main(argv=None):
766
773
  if args.parallel is not None and args.parallel < 1:
767
774
  sys.exit("--parallel must be 1 or more")
768
775
  parallel = args.parallel or e.parallel_models()
776
+ if args.parallel_hosted is not None and args.parallel_hosted < 1:
777
+ sys.exit("--parallel-hosted must be 1 or more")
778
+ parallel_hosted = args.parallel_hosted or e.parallel_hosted()
769
779
  models = e.cfg["models"]
770
780
  labels = [m["label"] for m in models if not m.get("remote")] # hosted APIs cost money: only by name
771
781
  if args.only:
@@ -800,7 +810,7 @@ def main(argv=None):
800
810
  if args.tests:
801
811
  tests, pack_names = parse_tests(args.tests, pack_names if (args.packs or args.preset) else None, e.packs)
802
812
  try:
803
- jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests)
813
+ jobs = e.plan(labels, pack_names, args.repeat, args.tier, args.force, tests, args.stop_early or None)
804
814
  except packs_mod.PackError as err:
805
815
  sys.exit(str(err))
806
816
 
@@ -834,12 +844,12 @@ def main(argv=None):
834
844
 
835
845
  signal.signal(signal.SIGINT, stop)
836
846
  signal.signal(signal.SIGTERM, stop)
837
- side_by_side = min(parallel, len({j.label for j in jobs if j.status == "waiting"}))
847
+ side_by_side = e.models_at_once({j.label for j in jobs if j.status == "waiting"}, parallel, parallel_hosted)
838
848
  if side_by_side > 1:
839
849
  printer.side_by_side = True
840
850
  say(f"up to {side_by_side} models at a time: one line per answer; each answer notes the models "
841
851
  "it ran alongside, since they share this machine's speed")
842
- worker = threading.Thread(target=e.run, args=(jobs, parallel))
852
+ worker = threading.Thread(target=e.run, args=(jobs, parallel, parallel_hosted))
843
853
  worker.start()
844
854
  while worker.is_alive():
845
855
  worker.join(0.5)
tuieval/tui.py CHANGED
@@ -390,6 +390,10 @@ picked with e, if any). Without it, answered questions are skipped. Before anyth
390
390
  the packs with earlier answers and lets you choose for each: Zero (ask everything again), Continue
391
391
  (keep what's answered, ask the rest) or Leave out. Replaced answers go to history/, never deleted.
392
392
 
393
+ [b]Stop a pack once it fails[/b]: stop each pack as soon as its FAIL is certain (a critical failure, or
394
+ an accuracy or truncation bar out of reach) and move on, asking first the questions models fail most.
395
+ Packs with stop_early = true in pack.toml always do this. A later Certify fills in the rest.
396
+
393
397
  [b]Keys[/b]
394
398
  s start (with a run going: add to it, or queue it) r results
395
399
  a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
@@ -652,6 +656,8 @@ class SetupScreen(Screen):
652
656
  yield Label("Models at a time")
653
657
  yield Input("", id="parallel", type="integer", max_length=1, placeholder="1")
654
658
  yield Checkbox("Retest (ask again, even if already answered)", id="force")
659
+ with Horizontal(id="options2"):
660
+ yield Checkbox("Stop a pack once it fails (a critical failure, or a bar out of reach)", id="stop-early")
655
661
  yield Static(id="estimate")
656
662
  with Horizontal(id="buttons"):
657
663
  yield Button("Start [s]", id="start", variant="success")
@@ -969,7 +975,7 @@ class SetupScreen(Screen):
969
975
  resumed = [j for j in run if "done earlier" in j.note]
970
976
  if resumed:
971
977
  text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
972
- at_once = min(self.parallel() or e.parallel_models(), len({j.label for j in run}))
978
+ at_once = e.models_at_once({j.label for j in run}, self.parallel())
973
979
  if at_once > 1:
974
980
  text += (f"\n[cyan]Up to {at_once} models at a time[/cyan][dim] (the time above assumes one at a "
975
981
  "time). Answers note the models they ran alongside, since those share the machine's "
@@ -1009,6 +1015,8 @@ class SetupScreen(Screen):
1009
1015
  self.query_one(f"#tier-{tier}", RadioButton).value = True
1010
1016
  if "force" in sel:
1011
1017
  self.query_one("#force", Checkbox).value = bool(sel["force"])
1018
+ if "stop_early" in sel:
1019
+ self.query_one("#stop-early", Checkbox).value = bool(sel["stop_early"])
1012
1020
  self.query_one("#filter", Input).value = ""
1013
1021
  self.refresh_models()
1014
1022
  if not quiet:
@@ -1043,7 +1051,7 @@ class SetupScreen(Screen):
1043
1051
  return
1044
1052
  picks = self.current_picks(suites)
1045
1053
  sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
1046
- "parallel": self.parallel()}
1054
+ "parallel": self.parallel(), "stop_early": self.query_one("#stop-early", Checkbox).value}
1047
1055
  e = self.app.engine
1048
1056
  jobs = e.plan(labels, suites, repeat, tier, True, picks)
1049
1057
  if not any(j.status == "waiting" for j in jobs):
@@ -1573,7 +1581,7 @@ class QueuedRun:
1573
1581
  """(jobs, None), or (None, why it can't run) now."""
1574
1582
  sel = self.sel
1575
1583
  jobs = [j for j in eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], self.retest,
1576
- self.tests(eng)) if j.key not in self.leave]
1584
+ self.tests(eng), sel.get("stop_early") or None) if j.key not in self.leave]
1577
1585
  waiting = [j for j in jobs if j.status == "waiting"]
1578
1586
  if not waiting:
1579
1587
  return None, "every pack left is already done"
@@ -2122,8 +2130,7 @@ class RunScreen(Screen):
2122
2130
  @property
2123
2131
  def side_by_side(self):
2124
2132
  """Several models run at a time in this run."""
2125
- return min(self.parallel or self.app.engine.parallel_models(),
2126
- len({j.label for j in self.jobs if j.status != "skipped"})) > 1
2133
+ return self.app.engine.models_at_once({j.label for j in self.jobs if j.status != "skipped"}, self.parallel) > 1
2127
2134
 
2128
2135
  def active(self):
2129
2136
  """Models being loaded or run now, in queue order."""
@@ -2438,7 +2445,7 @@ class RunScreen(Screen):
2438
2445
  rough |= j.key in self.rough
2439
2446
  left += (j.total - j.done) * spr
2440
2447
  models.add(j.label)
2441
- lanes = max(1, min(self.parallel or self.app.engine.parallel_models(), len(models)))
2448
+ lanes = max(1, self.app.engine.models_at_once(models, self.parallel))
2442
2449
  return left / lanes, rough
2443
2450
 
2444
2451
  def update_overall(self):
@@ -3076,7 +3083,7 @@ class EvalsApp(App):
3076
3083
  #tier-row Label { padding: 1 1 0 0; }
3077
3084
  #tier-row RadioSet { layout: horizontal; width: auto; height: auto; }
3078
3085
  #tier-row RadioButton { width: auto; margin-right: 2; }
3079
- #options { height: 3; padding: 0 1; align-vertical: middle; }
3086
+ #options, #options2 { height: 3; padding: 0 1; align-vertical: middle; }
3080
3087
  #options Label { padding: 1 1 0 0; }
3081
3088
  #options Input { width: 12; }
3082
3089
  #options #parallel { width: 8; }
tuieval/verdict.py CHANGED
@@ -73,6 +73,26 @@ def consistency(records):
73
73
  return ok / len(full), len(full)
74
74
 
75
75
 
76
+ def fail_certain(pack, records, repeat_default=3):
77
+ """Why this pack's verdict is FAIL whatever the remaining answers of its certification say,
78
+ or None: a critical failure, or a bar out of reach even if every answer still to come passes
79
+ (accuracy) or none of them is cut off (truncation). What an early stop checks after each answer."""
80
+ crit = next((r for r in records if r.get("severity") == "critical"), None)
81
+ if crit:
82
+ return f"critical failure ({crit['test']})"
83
+ n = len(records)
84
+ total = max(n, len(pack.tests) * (pack.certify_repeat or repeat_default))
85
+ passed = sum(bool(r["pass"]) for r in records)
86
+ bar = pack.gate.get("min_accuracy", 0.8)
87
+ if n and wilson(passed + total - n, total)[1] < bar:
88
+ return (f"{n - passed} wrong of {n}: under the {100 * bar:.0f}% bar even if all "
89
+ f"{total - n} answers left pass")
90
+ cut = sum(r.get("finish") == "length" for r in records)
91
+ if "max_truncation" in pack.gate and cut / total > pack.gate["max_truncation"]:
92
+ return f"{cut} answers cut off by max_tokens: over the {100 * pack.gate['max_truncation']:g}% limit for the pack"
93
+ return None
94
+
95
+
76
96
  def evaluate(pack, records, repeat_default=3, speed_gate=False):
77
97
  """Quality verdict for one model on one pack. `records` are result rows from
78
98
  results/<label>/<pack>.json. The p90 time limit is judged per machine by latency() unless
@@ -112,6 +132,9 @@ def evaluate(pack, records, repeat_default=3, speed_gate=False):
112
132
  if hi < min_acc:
113
133
  fails.append(f"accuracy {100 * passed / n:.1f}% (95% CI {100 * lo:.0f}-{100 * hi:.0f}%) is below "
114
134
  f"the {100 * min_acc:.0f}% bar")
135
+ certain = fail_certain(pack, records, repeat_default)
136
+ if certain and not crit and not fails: # a run stopped early (or a partial one) that can't pass any more
137
+ fails.append(certain)
115
138
  if "min_consistency" in gate and cons is not None and certified and cons < gate["min_consistency"]:
116
139
  fails.append(f"only {100 * cons:.0f}% of {groups} rephrased cases answered consistently "
117
140
  f"(needs {100 * gate['min_consistency']:.0f}%)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev77
3
+ Version: 0.2.0.dev81
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
@@ -1,26 +1,26 @@
1
1
  tuieval/__init__.py,sha256=myVXVX7htbwezW6k3x_FuKgn1RSZ2WcTaDEMByGn6Wg,312
2
2
  tuieval/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
- tuieval/_version.py,sha256=qX-ZwL3llLIy2WRHxAygX62S9Li2VQEb17v_xj4E0wE,535
3
+ tuieval/_version.py,sha256=wvLQcBRh9Ke_k0afcu8VifHptYL1UWBFOCSFcnpeLi0,535
4
4
  tuieval/builtin.toml,sha256=Kxw22RZCJhURUyu5Wz8tj5Y1nM85A8hFVRbA4ocoPUg,4293
5
5
  tuieval/cli.py,sha256=VwiaU1F9bZtU8C-gEowJq58kPeYQY4t6SY_HwrV-qgc,4702
6
6
  tuieval/client.py,sha256=kpsyLEZKfxCsvIGBHBbBy83TpI97CvnGCDq-4AbJS4Y,9986
7
7
  tuieval/compare.py,sha256=uXU_Yzf4zDSHoupHQrTmBhsfc8wCyI1nMaZQN81ybiA,30522
8
8
  tuieval/doctor.py,sha256=j2MdDORstsAGPnRmpsnxau1_fR6sTc_oEFGM5zugTJU,7246
9
- tuieval/engine.py,sha256=pGGlumC65FJMy9Jnpni4FoFNXhUHBDLgHYXi_A3qv7k,118847
9
+ tuieval/engine.py,sha256=yMWvYq85WmYmk8fpMLRzYcxJxo7XcjEIUTa_y2VK7Kk,124538
10
10
  tuieval/export.py,sha256=dbHQgL_ubWtIPM1sLzAew_gETnrDJyd0zgqVA5vkpXM,9657
11
11
  tuieval/logs.py,sha256=01gZK6tf53dIThaIrs1bEsz9eqKYVlcxpyaLnSPTVxo,8127
12
12
  tuieval/machines.py,sha256=t9tu6CZeXZON4EtOVnEwP4VtaynpVLf9Cz8TNjf7jtY,20124
13
13
  tuieval/onboard.py,sha256=IYETVDWCCo9vF0Dtj4_rr9VanYeCky8PwP3-2g5A_l0,6215
14
- tuieval/packs.py,sha256=tiLmxsrX_M5EUMuR4Yvb7pLBLEI15wamHSjflD6wjoY,11299
14
+ tuieval/packs.py,sha256=8bkF3xIDnWlt1JD2t3seHpMXW_RbMBgrCdIGEC-w-ZU,11458
15
15
  tuieval/profiles.py,sha256=soeLxjdB-l-OxSY8jZ4yjwfdSnsBV-QICcwKxRJS7nQ,3663
16
16
  tuieval/pta.py,sha256=i5oPj-Pz3N9u7bzzuZwsQvMGdxWdAncww2nR15K6PHY,12108
17
17
  tuieval/remove.py,sha256=Vwh43nm9lVb9U9aN-h6CMYbkTwdR3UWfZObwweF72n0,5157
18
- tuieval/run_evals.py,sha256=LtzXYWlPDdYKMWLmrmCpD6ff2ALLQRcUlgXGUdIFjQE,47005
18
+ tuieval/run_evals.py,sha256=FU3bvu3jt9ZWg3YLsXNINbdeClXMioyc4zQ4yEhmBQs,47855
19
19
  tuieval/scaffold.py,sha256=YBOqniJpf_GtKKAFF3_OHxxMGbmTWdFjcClGcOnrrKE,12130
20
20
  tuieval/selftest.py,sha256=nExkrOidgymlmvkxImEBfrQG5iTUN5WuRMIKtqusCCs,9835
21
- tuieval/tui.py,sha256=MUHk9EDMpqWa-dn-fuC2LZ9F6C_wJ6A2YTKyU6XcDdg,172739
21
+ tuieval/tui.py,sha256=bGAy0cP55rWkg9Cj3iAsSqR3tNM67Cj3G3nfvCitN8A,173351
22
22
  tuieval/tune.py,sha256=azxxthgJeag8JyoQUgaRxq6oI1XvEgzb9vkszjJZH7w,39645
23
- tuieval/verdict.py,sha256=VB-0LTxR9y8bJHT9RicxtE9K7cUHBdCu40hOCrsbmA4,19150
23
+ tuieval/verdict.py,sha256=3SM8qrVdWNIFDENagaR5ZPBYEnjsBtkYB9WS5TDVssU,20517
24
24
  tuieval/watch_proxy.py,sha256=bnDO1i6n6yUs1hODoUWcMppJMGPavoQqs6aNYrIxpT8,11416
25
25
  tuieval/workspace.py,sha256=V_HhkaN1odr0OpjNsPYradRWLpzDldM7SwpvxohBL4U,895
26
26
  tuieval/yamlout.py,sha256=r1IokjLqW-Ztpg1fSn33ZW3VK00v1VvxymmU5Qy7GEw,606
@@ -47,8 +47,8 @@ tuieval/templates/packs/tool_call/pack.toml,sha256=n4bx0lGJHSmW41uVqhfIXJ2BWZpmT
47
47
  tuieval/templates/packs/tool_call/system.txt,sha256=mKxu-lB-0YjLHXdgeFJa8x2C180OvcNz5cHv2N5Bg3U,254
48
48
  tuieval/templates/packs/tool_call/tests.yaml,sha256=Nv7c4syrzJ9cU8EBKSK_1ZSFx9V02aCjW4-2TBCPT7s,1859
49
49
  tuieval/templates/packs/tool_call/tools.yaml,sha256=HgxCkh0On2VwbHSsjAGM8tNCK7ZtKLGrs7lMCn3Fprs,1048
50
- tuieval-0.2.0.dev77.dist-info/METADATA,sha256=LvwdGOxerLqRU5dR6g4Soi1TYEx3k1i32ei87hi9xnA,14580
51
- tuieval-0.2.0.dev77.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
- tuieval-0.2.0.dev77.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
53
- tuieval-0.2.0.dev77.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
54
- tuieval-0.2.0.dev77.dist-info/RECORD,,
50
+ tuieval-0.2.0.dev81.dist-info/METADATA,sha256=362fng3M9sOKj7AGpBf-ebNRTQgTjW84h1nfrn3YeKg,14580
51
+ tuieval-0.2.0.dev81.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
+ tuieval-0.2.0.dev81.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
53
+ tuieval-0.2.0.dev81.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
54
+ tuieval-0.2.0.dev81.dist-info/RECORD,,