tuieval 0.2.0.dev23__py3-none-any.whl → 0.2.0.dev28__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.dev23'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev23')
21
+ __version__ = version = '0.2.0.dev28'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev28')
23
23
 
24
24
  __commit_id__ = commit_id = None
tuieval/compare.py CHANGED
@@ -37,7 +37,8 @@ def _load_native(data):
37
37
  "settings": data.get("settings", {}), "load_s": run.get("load_s"),
38
38
  "peak_rss_mb": run.get("peak_rss_mb"), "pack": pack["name"], "pack_fp": pack["fingerprint"],
39
39
  # hosted (OpenRouter) models: the provider endpoint and its declared quantization
40
- "endpoint": run.get("endpoint"), "quantization": run.get("quantization")}
40
+ "endpoint": run.get("endpoint"), "quantization": run.get("quantization"),
41
+ "loaded_alongside": run.get("loaded_alongside") or []}
41
42
  rows = []
42
43
  for r in data["results"]:
43
44
  rows.append({
@@ -54,6 +55,8 @@ def _load_native(data):
54
55
  "severity": r.get("severity"), "group": r.get("group"), "repeat": r.get("repeat", 0),
55
56
  "difficulty": r.get("difficulty", "unrated"),
56
57
  "machine": r.get("machine") or run.get("machine"), "prompt_tokens": r.get("prompt_tokens"),
58
+ # other models served at the same time (parallel_models): its speed was shared with them
59
+ "alongside": r.get("ran_alongside") or [],
57
60
  })
58
61
  return rows, info
59
62
 
@@ -210,6 +213,13 @@ def timing(r, machine=None, speeds=None):
210
213
  return None, None, None, None, None
211
214
 
212
215
 
216
+ def alongside_note(rows):
217
+ """' · n/N ran alongside others' when some answers ran while other models were served (their
218
+ speed was shared with them), else ''."""
219
+ shared = sum(bool(r.get("alongside")) for r in rows)
220
+ return f" · {shared}/{len(rows)} ran alongside others" if shared else ""
221
+
222
+
213
223
  def speed(rows, infos, machine=None, speeds=None):
214
224
  """(header, table, frontier): speed and token use per model.
215
225
 
@@ -227,7 +237,9 @@ def speed(rows, infos, machine=None, speeds=None):
227
237
  load = {}
228
238
  for i in infos:
229
239
  cur = load.setdefault(i["label"], {"load_s": None, "peak_rss_mb": None, "tags": i["tags"],
230
- "think": i["settings"].get("enable_thinking"), "served": set()})
240
+ "think": i["settings"].get("enable_thinking"), "served": set(),
241
+ "loaded_alongside": False})
242
+ cur["loaded_alongside"] |= bool(i.get("loaded_alongside"))
231
243
  if i.get("endpoint"):
232
244
  q = i.get("quantization")
233
245
  cur["served"].add(f"{'undeclared' if q in (None, 'unknown') else q} via {i['endpoint']}")
@@ -247,6 +259,7 @@ def speed(rows, infos, machine=None, speeds=None):
247
259
  here = machine is None or machine in where # load time and memory only where it ran
248
260
  if machine is None:
249
261
  kind = "measured on " + (", ".join(where) or "?")
262
+ kind += alongside_note(v)
250
263
  table.append([
251
264
  "★" if m in frontier else "", m, "; ".join(sorted(info.get("served") or [])) or "-",
252
265
  ",".join(info.get("tags") or []),
@@ -256,7 +269,8 @@ def speed(rows, infos, machine=None, speeds=None):
256
269
  _fmt(median(x[3] for x in t), ".0f"), _fmt(median(r["tokens"] for r in v), ".0f"),
257
270
  _fmt(median(r["reasoning_tokens"] for r in v), ".0f"), _fmt(median(r["answer_tokens"] for r in v), ".0f"),
258
271
  f"{sum(r['tokens'] for r in v) / passes:,.0f}" if passes else "-",
259
- _fmt(info.get("load_s") if here else None, ".0f"),
272
+ _fmt(info.get("load_s") if here else None, ".0f")
273
+ + (" (alongside others)" if here and info.get("load_s") and info.get("loaded_alongside") else ""),
260
274
  _fmt(info["peak_rss_mb"] / 1024 if info.get("peak_rss_mb") and here else None, ".1f"),
261
275
  ])
262
276
  return header, table, frontier
tuieval/engine.py CHANGED
@@ -229,6 +229,7 @@ class Serving:
229
229
  identity: dict # output-affecting settings, part of the results fingerprint
230
230
  profile: dict | None = None
231
231
  mtp_layers: int = 0
232
+ need_gb: float | None = None # estimated GPU memory at ctx (from the fit check); None when unknown
232
233
 
233
234
 
234
235
  def infer_server(cfg, model):
@@ -664,6 +665,20 @@ def holder_text(holder):
664
665
  "One eval or tune per machine: this starts when it ends")
665
666
 
666
667
 
668
+ class Lane:
669
+ """One model being run: its own request stream, stall reason and skip switch, so models run
670
+ side by side (parallel_models) stop independently. Runs and tunes in one lane use the engine's
671
+ main lane."""
672
+ def __init__(self, label=None):
673
+ self.label = label
674
+ self.stream = None
675
+ self.stall = None # set by the stall watcher: why this model's server stalled
676
+ self.skip = threading.Event()
677
+ self.procs = []
678
+ self.port = None
679
+ self.seen = set() # other models served during this model's current load or request
680
+
681
+
667
682
  class Engine:
668
683
  def __init__(self, on_event=None, root=None, models_path=None, packs_dir=None,
669
684
  results_dir=None, log_dir=None):
@@ -677,11 +692,12 @@ class Engine:
677
692
  self.pack_errors = []
678
693
  self.packs = packs_mod.load_packs(self.packs_dir, self.pack_errors)
679
694
  self._cancel = threading.Event()
680
- self._skip = threading.Event()
681
- self._stall = None # set by the stall watcher: why the current server stalled
695
+ self._main_lane = Lane()
696
+ self._here = threading.local() # .lane: the lane this thread runs
697
+ self._lanes = {} # label -> Lane of each model being served now
698
+ self._lanes_lock = threading.Lock()
682
699
  self._procs = []
683
700
  self._stop_lock = threading.Lock()
684
- self._stream = None
685
701
  self._header_cache = {}
686
702
  self._serving = {}
687
703
  self.tuning_dir = os.path.join(root, "tuning")
@@ -690,6 +706,34 @@ class Engine:
690
706
  self._catalogues = {}
691
707
  self._add_remote_models()
692
708
 
709
+ # ---- the lane this thread runs (see Lane)
710
+ def _lane(self):
711
+ return getattr(self._here, "lane", None) or self._main_lane
712
+
713
+ def _all_lanes(self):
714
+ with self._lanes_lock:
715
+ return [self._main_lane, *self._lanes.values()]
716
+
717
+ @property
718
+ def _stream(self):
719
+ return self._lane().stream
720
+
721
+ @_stream.setter
722
+ def _stream(self, value):
723
+ self._lane().stream = value
724
+
725
+ @property
726
+ def _stall(self):
727
+ return self._lane().stall
728
+
729
+ @_stall.setter
730
+ def _stall(self, value):
731
+ self._lane().stall = value
732
+
733
+ @property
734
+ def _skip(self):
735
+ return self._lane().skip
736
+
693
737
  def reload(self):
694
738
  self._serving = {}
695
739
  self.cfg = load_config(self.models_path)
@@ -1150,6 +1194,30 @@ class Engine:
1150
1194
  """models.toml [machines.<id>] (optional): memory_headroom_gb, name."""
1151
1195
  return self.cfg.get("machines", {}).get(machine_id or self.machine().id, {})
1152
1196
 
1197
+ def parallel_models(self):
1198
+ """How many models a run serves at a time on this machine: models.toml [machines.<id>]
1199
+ parallel_models (default 1)."""
1200
+ try:
1201
+ return max(1, int(self.machine_settings().get("parallel_models", 1)))
1202
+ except (TypeError, ValueError):
1203
+ return 1
1204
+
1205
+ def memory_need_gb(self, m):
1206
+ """GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
1207
+ server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
1208
+ if not self.cfg["servers"][m["server"]].get("cmd"):
1209
+ return 0.0
1210
+ return self.serving(m).need_gb
1211
+
1212
+ def memory_available_gb(self):
1213
+ """GPU memory models may take here (what the fit check sizes contexts against)."""
1214
+ mc = self.machine()
1215
+ return mc.gpu_limit_gb - self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1216
+
1217
+ def runs_alone(self, m):
1218
+ """A server whose before_start step may stop other servers never runs next to another model."""
1219
+ return bool(self.cfg["servers"][m["server"]].get("before_start"))
1220
+
1153
1221
  def gpu_residency_gb(self, machine=None):
1154
1222
  """GPU memory this machine's driver keeps resident without churning (see machines.py)."""
1155
1223
  mc = machine or self.machine()
@@ -1180,7 +1248,7 @@ class Engine:
1180
1248
  kv = m.get("kv_type") or server.get("kv_type")
1181
1249
  want = min(x for x in (server.get("max_ctx"), m.get("ctx"), m.get("max_context"), 10**9) if x)
1182
1250
  want = None if want == 10**9 else want
1183
- ctx, fits, note, mtp, size = want, True, "", 0, None
1251
+ ctx, fits, note, mtp, size, need = want, True, "", 0, None, None
1184
1252
  path = expand(m["model"])
1185
1253
  if server.get("model_is_path") and os.path.isfile(path):
1186
1254
  try:
@@ -1192,7 +1260,7 @@ class Engine:
1192
1260
  headroom = self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1193
1261
  f = machines.fit(path, mc, kv_type=kv or "f16", headroom_gb=headroom, extra_bytes=extra,
1194
1262
  want_ctx=want)
1195
- ctx, fits, note = f.max_ctx, f.fits, f.note
1263
+ ctx, fits, note, need = f.max_ctx, f.fits, f.note, f.need_gb
1196
1264
  except (OSError, ValueError) as e:
1197
1265
  note = f"couldn't read the GGUF header ({e}); context not checked"
1198
1266
  profile, perf, source = None, [], "n/a"
@@ -1221,7 +1289,7 @@ class Engine:
1221
1289
  # resident), so both are part of what a result is valid for.
1222
1290
  identity["machine"] = mc.id
1223
1291
  identity["perf"] = perf
1224
- sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp)
1292
+ sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp, need)
1225
1293
  self._serving[key] = sv
1226
1294
  return sv
1227
1295
 
@@ -1289,16 +1357,21 @@ class Engine:
1289
1357
  # ---- control
1290
1358
  def cancel(self):
1291
1359
  self._cancel.set()
1292
- self._abort()
1293
-
1294
- def skip_model(self):
1295
- self._skip.set()
1296
- self._abort()
1297
-
1298
- def _abort(self):
1299
- if self._stream:
1300
- self._stream.close()
1301
- for p in list(self._procs):
1360
+ self._abort(self._all_lanes(), list(self._procs))
1361
+
1362
+ def skip_model(self, label=None):
1363
+ """Skip a model being run (label), or every one being run now."""
1364
+ with self._lanes_lock:
1365
+ lanes = [self._lanes[label]] if label in self._lanes else list(self._lanes.values()) or [self._main_lane]
1366
+ for lane in lanes:
1367
+ lane.skip.set()
1368
+ self._abort(lanes, [p for lane in lanes for p in lane.procs] if self._lanes else list(self._procs))
1369
+
1370
+ def _abort(self, lanes, procs):
1371
+ for lane in lanes:
1372
+ if lane.stream:
1373
+ lane.stream.close()
1374
+ for p in procs:
1302
1375
  self._stop(p, grace=10)
1303
1376
 
1304
1377
  def _check(self):
@@ -1322,6 +1395,7 @@ class Engine:
1322
1395
  STALL_WINDOW_S, the GPU driver is churning memory: record why and abort the request."""
1323
1396
  mc = self.machine()
1324
1397
  limit = self.gpu_residency_gb(mc)
1398
+ lane = self._lane()
1325
1399
  samples = [] # (time, user, kernel)
1326
1400
 
1327
1401
  def run():
@@ -1343,15 +1417,15 @@ class Engine:
1343
1417
  continue
1344
1418
  share = dk / (du + dk)
1345
1419
  info["kernel_share_max"] = max(info.get("kernel_share_max") or 0, round(share, 2))
1346
- if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not self._stall:
1420
+ if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not lane.stall:
1347
1421
  over = f"{alloc:.1f} GB of GPU memory allocated; " if alloc else ""
1348
- self._stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
1422
+ lane.stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
1349
1423
  f"CPU time went to the kernel for {self.STALL_WINDOW_S}s while the GPU waited "
1350
1424
  f"({over}this machine keeps ~{limit:.0f} GB resident). "
1351
1425
  "Lower its GPU memory or retune it.")
1352
- self.emit("server_stall", label=m["label"], message=self._stall)
1353
- if self._stream:
1354
- self._stream.close()
1426
+ self.emit("server_stall", label=m["label"], message=lane.stall)
1427
+ if lane.stream:
1428
+ lane.stream.close()
1355
1429
  threading.Thread(target=run, daemon=True).start()
1356
1430
 
1357
1431
  # ---- processes
@@ -1360,6 +1434,7 @@ class Engine:
1360
1434
  p = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=out,
1361
1435
  stderr=subprocess.STDOUT, start_new_session=True)
1362
1436
  self._procs.append(p)
1437
+ self._lane().procs.append(p)
1363
1438
  return p
1364
1439
 
1365
1440
  def _stop(self, p, grace=30):
@@ -1381,6 +1456,9 @@ class Engine:
1381
1456
  p.wait()
1382
1457
  if p in self._procs:
1383
1458
  self._procs.remove(p)
1459
+ for lane in self._all_lanes():
1460
+ if p in lane.procs:
1461
+ lane.procs.remove(p)
1384
1462
 
1385
1463
  def _tail(self, path, label, stop):
1386
1464
  def run():
@@ -1472,8 +1550,9 @@ class Engine:
1472
1550
  return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
1473
1551
 
1474
1552
  # ---- running
1475
- def run(self, jobs):
1476
- """Run the jobs (blocking). Jobs of the same model share one server start."""
1553
+ def run(self, jobs, parallel=None):
1554
+ """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1555
+ models are served at a time (default: parallel_models for this machine)."""
1477
1556
  self._cancel.clear()
1478
1557
  self.emit("queue_started", jobs=jobs)
1479
1558
  models = sorted({j.label for j in jobs if j.status == "waiting"})
@@ -1486,25 +1565,10 @@ class Engine:
1486
1565
  for j in jobs:
1487
1566
  if j.label not in order:
1488
1567
  order.append(j.label)
1489
- for label in order:
1490
- mine = [j for j in jobs if j.label == label and j.status == "waiting"]
1491
- if not mine:
1492
- continue
1493
- self._skip.clear()
1494
- try:
1495
- try:
1496
- self._run_model(mine)
1497
- except (Cancelled, ModelFailed):
1498
- raise
1499
- except Exception as e: # never let one model's surprise kill the whole queue
1500
- raise ModelFailed(f"unexpected error: {e!r}") from e
1501
- except ModelFailed as e:
1502
- for j in mine:
1503
- if j.status in ("waiting", "loading", "running"):
1504
- j.status = "skipped" if "by user" in str(e) else "failed"
1505
- j.note, j.finished = str(e).splitlines()[0], time.time()
1506
- self.emit("job_done", job=j)
1507
- self.emit("model_failed", label=label, message=str(e))
1568
+ groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1569
+ self._run_lanes([g for g in groups if g[1]], max(1, int(parallel or self.parallel_models())))
1570
+ if self._cancel.is_set():
1571
+ raise Cancelled()
1508
1572
  except Cancelled:
1509
1573
  for j in jobs:
1510
1574
  if j.status in ("waiting", "loading", "running"):
@@ -1522,6 +1586,112 @@ class Engine:
1522
1586
  self.emit("verdicts", changes=changes)
1523
1587
  self.emit("queue_done", jobs=jobs, cancelled=self._cancel.is_set())
1524
1588
 
1589
+ def _run_lanes(self, groups, parallel):
1590
+ """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1591
+ model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1592
+ next to the ones running (memory_need_gb; an unknown need is left to the setting)."""
1593
+ pending, running, told = list(groups), {}, set()
1594
+ cond = threading.Condition()
1595
+
1596
+ def blocked(label):
1597
+ """Why the next model can't start now (None: it can)."""
1598
+ if not running:
1599
+ return None
1600
+ if len(running) >= parallel:
1601
+ return ""
1602
+ m = self.model(label)
1603
+ if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
1604
+ alone = label if self.runs_alone(m) else next(l for l in running if self.runs_alone(self.model(l)))
1605
+ return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
1606
+ "on its own")
1607
+ need = self.memory_need_gb(m)
1608
+ used = sum(n for n in running.values() if n)
1609
+ room = self.memory_available_gb()
1610
+ if need and used + need > room:
1611
+ return (f"needs ~{need:.0f} GB; {', '.join(running)} take ~{used:.0f} of {room:.0f} GB, so it "
1612
+ "starts when there's room")
1613
+ return None
1614
+
1615
+ def lane():
1616
+ while True:
1617
+ with cond:
1618
+ while True:
1619
+ if self._cancel.is_set() or not pending:
1620
+ return
1621
+ label = pending[0][0]
1622
+ why = blocked(label)
1623
+ if why is None:
1624
+ label, mine = pending.pop(0)
1625
+ running[label] = self.memory_need_gb(self.model(label))
1626
+ break
1627
+ if why and label not in told:
1628
+ told.add(label)
1629
+ self.emit("model_waiting", label=label, message=f"{label} waits: {why}")
1630
+ cond.wait(1.0)
1631
+ try:
1632
+ self._run_lane(label, mine)
1633
+ finally:
1634
+ with cond:
1635
+ running.pop(label, None)
1636
+ cond.notify_all()
1637
+
1638
+ if parallel == 1 or len(groups) == 1:
1639
+ lane()
1640
+ return
1641
+ threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(min(parallel, len(groups)))]
1642
+ for t in threads:
1643
+ t.start()
1644
+ for t in threads:
1645
+ t.join()
1646
+
1647
+ def _run_lane(self, label, mine):
1648
+ """One model's jobs, in this thread's own lane. Other models served meanwhile are noted on
1649
+ each answer (ran_alongside), since they share the machine's GPU and memory bandwidth."""
1650
+ lane = Lane(label)
1651
+ m = mine[0].model
1652
+ with self._lanes_lock:
1653
+ for other in self._lanes.values():
1654
+ other.seen.add(label)
1655
+ lane.seen = set(self._lanes)
1656
+ if self.cfg["servers"][m["server"]].get("cmd"):
1657
+ lane.port = self._lane_port(m)
1658
+ self._lanes[label] = lane
1659
+ self._here.lane = lane
1660
+ try:
1661
+ try:
1662
+ self._run_model(mine)
1663
+ except (Cancelled, ModelFailed):
1664
+ raise
1665
+ except Exception as e: # never let one model's surprise kill the whole queue
1666
+ raise ModelFailed(f"unexpected error: {e!r}") from e
1667
+ except ModelFailed as e:
1668
+ for j in mine:
1669
+ if j.status in ("waiting", "loading", "running"):
1670
+ j.status = "skipped" if "by user" in str(e) else "failed"
1671
+ j.note, j.finished = str(e).splitlines()[0], time.time()
1672
+ self.emit("job_done", job=j)
1673
+ self.emit("model_failed", label=label, message=str(e))
1674
+ except Cancelled:
1675
+ pass # run() marks what's left
1676
+ finally:
1677
+ self._here.lane = None
1678
+ with self._lanes_lock:
1679
+ self._lanes.pop(label, None)
1680
+
1681
+ def _lane_port(self, m):
1682
+ """The server's port, or a free one when another model being served has it (call with
1683
+ _lanes_lock held)."""
1684
+ port = self.cfg["servers"][m["server"]].get("port")
1685
+ if port is None or not any(l.port == port for l in self._lanes.values()):
1686
+ return port
1687
+ taken = {l.port for l in self._lanes.values()}
1688
+ while True:
1689
+ with socket.socket() as s:
1690
+ s.bind(("127.0.0.1", 0))
1691
+ free = s.getsockname()[1]
1692
+ if free not in taken:
1693
+ return free
1694
+
1525
1695
  @contextlib.contextmanager
1526
1696
  def serve(self, m, perf_args=None, port=None, log_name=None):
1527
1697
  """Start a model's server (or use the running one), wait until it serves the expected name,
@@ -1649,11 +1819,16 @@ class Engine:
1649
1819
  for j in jobs:
1650
1820
  j.status = "loading"
1651
1821
  self.emit("job_update", job=j)
1652
- with self.serve(m) as (base_url, info):
1822
+ lane = self._lane()
1823
+ with self.serve(m, port=lane.port) as (base_url, info):
1824
+ with self._lanes_lock:
1825
+ loaded_alongside = sorted(lane.seen) # served while this one loaded: its load time is shared
1826
+
1653
1827
  def run_info():
1654
1828
  return {"load_s": info["load_s"], "peak_rss_mb": info["peak_mb"](),
1655
1829
  "server_facts": info["facts"] or None, "gpu_peak_gb": info.get("gpu_peak_gb"),
1656
- "kernel_share_max": info.get("kernel_share_max"), **serving_info}
1830
+ "kernel_share_max": info.get("kernel_share_max"),
1831
+ **({"loaded_alongside": loaded_alongside} if loaded_alongside else {}), **serving_info}
1657
1832
  failed_in_row = 0
1658
1833
  for j in jobs:
1659
1834
  self._check()
@@ -1736,7 +1911,7 @@ class Engine:
1736
1911
  return tests
1737
1912
 
1738
1913
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
1739
- SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine")
1914
+ SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine", "loaded_alongside")
1740
1915
 
1741
1916
  def _sitting(self, job, info):
1742
1917
  now = time.time()
@@ -1811,6 +1986,9 @@ class Engine:
1811
1986
  for k in ("peak_rss_mb", "gpu_peak_gb", "kernel_share_max"):
1812
1987
  if peak(k) is not None:
1813
1988
  out[k] = peak(k)
1989
+ along = sorted({l for s in sittings for l in s.get("loaded_alongside") or []})
1990
+ if along:
1991
+ out["loaded_alongside"] = along
1814
1992
  return out
1815
1993
 
1816
1994
  def _run_job(self, job, base_url, run_info, alive=lambda: True):
@@ -1862,6 +2040,9 @@ class Engine:
1862
2040
  if (test["id"], rep) in done_keys:
1863
2041
  continue
1864
2042
  self._check()
2043
+ lane = self._lane()
2044
+ with self._lanes_lock:
2045
+ lane.seen = set(self._lanes) - {job.label}
1865
2046
  self.emit("request_started", job=job, test=test["description"], test_id=test["id"],
1866
2047
  has_image=bool(test.get("image")), repeat=rep, difficulty=test.get("difficulty", "unrated"))
1867
2048
  waits = list(self.SERVER_RETRY_WAITS_S)
@@ -1870,7 +2051,7 @@ class Engine:
1870
2051
  try:
1871
2052
  body = self._body(job, test, rep)
1872
2053
  res = client.stream_chat(base_url, body,
1873
- lambda kind, text: self.emit("delta", stream=kind, text=text),
2054
+ lambda kind, text: self.emit("delta", stream=kind, text=text, label=job.label),
1874
2055
  timeout=timeout, stream=self._stream, headers=headers)
1875
2056
  except client.Cancelled:
1876
2057
  self._check()
@@ -1917,6 +2098,8 @@ class Engine:
1917
2098
  **({"checks": g["checks"]} if "checks" in g else {}),
1918
2099
  "machine": self.machine().id,
1919
2100
  **({"quantization": ep.get("quantization")} if ep else {}),
2101
+ # other models served while this answer ran: they shared the machine, so its speed did too
2102
+ **({"ran_alongside": sorted(lane.seen)} if lane.seen else {}),
1920
2103
  "sent": "+".join(msg["role"] for msg in body["messages"]), # proof: single turn
1921
2104
  **({"seed": body["seed"]} if "seed" in body else {}), **meta}
1922
2105
  # The same long answer twice for one question, with sampling on, means a cached response
tuieval/graders/code.py CHANGED
@@ -53,6 +53,7 @@ def _err(e):
53
53
  return last[:200]
54
54
  try:
55
55
  exec(compile(CANDIDATE, "candidate.py", "exec"), ns)
56
+ ns = dict(ns) # tests get a copy, so their imports can't rebind the candidate's globals
56
57
  exec(compile(SETUP, "setup.py", "exec"), ns)
57
58
  setup_error = None
58
59
  except BaseException as e:
tuieval/run_evals.py CHANGED
@@ -15,6 +15,7 @@
15
15
  tuieval run --tier smoke --only my-model # 3 tests per pack, 1 repeat: check paths and flags
16
16
  tuieval run --tier smoke --only openrouter:qwen/qwen3-32b # any OpenRouter model, no models.toml edit
17
17
  tuieval run --dry-run # print the plan and server commands only
18
+ tuieval run --parallel 2 # serve two models at a time (parallel_models in models.toml)
18
19
  tuieval add ~/models/New-Model-Q4_K_M.gguf # register a model (--vision/--mmproj, --no-think, --tags)
19
20
  tuieval scan # GGUFs in model_dirs that aren't registered yet
20
21
  tuieval list # the models tuieval knows; hidden ones listed separately
@@ -53,6 +54,7 @@ class Printer:
53
54
  def __init__(self):
54
55
  self.lock = threading.Lock()
55
56
  self.mode = None
57
+ self.side_by_side = False # several models at a time: one line per answer, no live streams
56
58
 
57
59
  def __call__(self, kind, **d):
58
60
  with self.lock:
@@ -73,6 +75,9 @@ class Printer:
73
75
  def on_server_stall(self, label, message):
74
76
  say(f"STALL: {message}", RED)
75
77
 
78
+ def on_model_waiting(self, label, message):
79
+ say(message, YELLOW)
80
+
76
81
  def on_server_retry(self, label, message):
77
82
  say(message, RED)
78
83
 
@@ -97,11 +102,15 @@ class Printer:
97
102
  + (f" ({earlier_s / 60:.0f} min over {sittings} earlier sitting(s))" if sittings else ""))
98
103
 
99
104
  def on_request_started(self, job, test, test_id, has_image, repeat, difficulty="unrated"):
105
+ if self.side_by_side:
106
+ return
100
107
  tag = {"easy": GREEN, "medium": "\033[33m", "hard": RED}.get(difficulty, DIM) + f"[{difficulty}]" + RESET
101
108
  print(f"\n{BOLD}{CYAN}━━ {job.key} [{job.done + 1}/{job.total}]{RESET} {tag} {BOLD}{CYAN}{test}{RESET}", flush=True)
102
109
  self.mode = None
103
110
 
104
- def on_delta(self, stream, text):
111
+ def on_delta(self, stream, text, label=None):
112
+ if self.side_by_side:
113
+ return
105
114
  if self.mode != stream:
106
115
  sys.stdout.write(f"{DIM}[thinking] " if stream == "reasoning" else f"{RESET}\n{BOLD}[answer]{RESET} ")
107
116
  self.mode = stream
@@ -114,8 +123,10 @@ class Printer:
114
123
  calls = f" tool={r['tool_calls'][0]['name']}" if r.get("tool_calls") else ""
115
124
  speed = f"{r['gen_tps']:.0f} tok/s, " if r.get("gen_tps") else ""
116
125
  ttft = f"TTFT {r['ttft_s']:.1f}s, " if r.get("ttft_s") is not None else ""
117
- print(f"{RESET}\n{mark} {r['description']}{RESET}{calls} {DIM}({r.get('completion_tokens')} tokens, "
118
- f"{ttft}{speed}{r['total_s']:.1f}s) {r['reason'][:120]}{RESET}", flush=True)
126
+ where = f"{CYAN}{job.key} [{job.done}/{job.total}]{RESET} " if self.side_by_side else ""
127
+ print(f"{RESET}{'' if self.side_by_side else chr(10)}{where}{mark} {r['description']}{RESET}{calls} "
128
+ f"{DIM}({r.get('completion_tokens')} tokens, {ttft}{speed}{r['total_s']:.1f}s) {r['reason'][:120]}{RESET}",
129
+ flush=True)
119
130
 
120
131
  def on_job_done(self, job):
121
132
  color = GREEN if job.status == "done" else RED if job.status == "failed" else ""
@@ -683,6 +694,8 @@ def main(argv=None):
683
694
  p.add_argument("--tier", choices=engine.TIERS, default="screen",
684
695
  help="smoke: 3 tests; screen (default): a spread sample; certify: everything, with repeats")
685
696
  p.add_argument("--smoke", action="store_const", const="smoke", dest="tier", help="same as --tier smoke")
697
+ p.add_argument("--parallel", type=int, metavar="N",
698
+ help="models served at a time (default: parallel_models for this machine in models.toml, else 1)")
686
699
  p.add_argument("--dry-run", action="store_true", help="print the plan and commands without running")
687
700
  p.add_argument("--brief", action="store_true", help="end with one line per model instead of the scorecards")
688
701
  p.add_argument("--models", default=None, help="default: the workspace's models.toml")
@@ -691,8 +704,12 @@ def main(argv=None):
691
704
  p.add_argument("--log-dir", help="server and request logs (default: logs/)")
692
705
  args = p.parse_args(argv)
693
706
 
694
- e = engine.Engine(Printer(), models_path=args.models, packs_dir=args.packs_dir,
707
+ printer = Printer()
708
+ e = engine.Engine(printer, models_path=args.models, packs_dir=args.packs_dir,
695
709
  results_dir=args.results_dir, log_dir=args.log_dir)
710
+ if args.parallel is not None and args.parallel < 1:
711
+ sys.exit("--parallel must be 1 or more")
712
+ parallel = args.parallel or e.parallel_models()
696
713
  models = e.cfg["models"]
697
714
  labels = [m["label"] for m in models if not m.get("remote")] # hosted APIs cost money: only by name
698
715
  if args.only:
@@ -757,7 +774,12 @@ def main(argv=None):
757
774
 
758
775
  signal.signal(signal.SIGINT, stop)
759
776
  signal.signal(signal.SIGTERM, stop)
760
- worker = threading.Thread(target=e.run, args=(jobs,))
777
+ side_by_side = min(parallel, len({j.label for j in jobs if j.status == "waiting"}))
778
+ if side_by_side > 1:
779
+ printer.side_by_side = True
780
+ say(f"up to {side_by_side} models at a time: one line per answer; each answer notes the models "
781
+ "it ran alongside, since they share this machine's speed")
782
+ worker = threading.Thread(target=e.run, args=(jobs, parallel))
761
783
  worker.start()
762
784
  while worker.is_alive():
763
785
  worker.join(0.5)
tuieval/tui.py CHANGED
@@ -290,6 +290,9 @@ HELP = {
290
290
  2. Pick a [b]tier[/b]: Smoke first to check the setup, then Screen, then Certify for finalists.
291
291
  3. Press [b]s[/b]. The line above the buttons says how many answers that is and roughly how long.
292
292
 
293
+ [b]Models at a time[/b]: how many of the ticked models run side by side (blank: this machine's
294
+ parallel_models in models.toml, default 1). Answers note the models they ran alongside.
295
+
293
296
  [b]Keys[/b]
294
297
  s start (or queue it behind a run that's going) r results
295
298
  a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
@@ -308,7 +311,8 @@ with the model's reasoning and answer as they stream, then recent results with t
308
311
  [b]Keys[/b]
309
312
  k skip the current model c cancel everything (finished answers are kept; it resumes next time)
310
313
  f pause or resume auto-scroll r results n set up a new run (this one keeps going)
311
- w runs and queue esc back to Setup (the run keeps going)""",
314
+ w runs and queue esc back to Setup (the run keeps going)
315
+ v with several models at a time: stream the next one (k skips the one streaming)""",
312
316
  "ResultsScreen": """[b]Results[/b]
313
317
 
314
318
  Start with [b]Production readiness[/b]: one verdict per model and use case, and what's missing when
@@ -435,6 +439,7 @@ class AnswerScreen(ModalScreen):
435
439
  f"{rec['gen_tps']:.0f} tok/s" if rec.get("gen_tps") else "",
436
440
  f"{rec['total_s']:.0f}s total" if rec.get("total_s") is not None else "",
437
441
  f"on {rec['machine']}" if rec.get("machine") else "",
442
+ f"[yellow]ran alongside {', '.join(rec['ran_alongside'])}[/yellow]" if rec.get("ran_alongside") else "",
438
443
  f"seed {rec['seed']}" if "seed" in rec else ""]
439
444
  self.query_one("#answer-head", Static).update(
440
445
  f"{mark} {diff_badge(rec.get('difficulty', 'unrated'))} [b]{rich_escape(rec.get('description', rec['test']))}[/b]"
@@ -544,6 +549,8 @@ class SetupScreen(Screen):
544
549
  with Horizontal(id="options"):
545
550
  yield Label("Repeats")
546
551
  yield Input("", id="repeat", type="integer", max_length=2, placeholder="1")
552
+ yield Label("Models at a time")
553
+ yield Input("", id="parallel", type="integer", max_length=1, placeholder="1")
547
554
  yield Checkbox("Rerun existing results", id="force")
548
555
  yield Static(id="estimate")
549
556
  with Horizontal(id="buttons"):
@@ -761,6 +768,13 @@ class SetupScreen(Screen):
761
768
  list(self.query_one("#suites", SelectionList).selected), repeat,
762
769
  self.tier(), self.query_one("#force", Checkbox).value)
763
770
 
771
+ def parallel(self):
772
+ """Models at a time for the next run: the field, or blank for this machine's parallel_models."""
773
+ try:
774
+ return max(1, int(self.query_one("#parallel", Input).value)) or None
775
+ except ValueError:
776
+ return None
777
+
764
778
  def current_picks(self, suites=None):
765
779
  """{pack: picked test ids} for packs that still have those tests (and, given suites, are ticked)."""
766
780
  e = self.app.engine
@@ -803,6 +817,7 @@ class SetupScreen(Screen):
803
817
  e = self.app.engine
804
818
  defaults = sorted({e.default_repeat(e.packs[p], tier) for p in suites}) or [1]
805
819
  self.query_one("#repeat", Input).placeholder = str(defaults[0]) if len(defaults) == 1 else "pack"
820
+ self.query_one("#parallel", Input).placeholder = str(e.parallel_models())
806
821
  if not e.packs:
807
822
  est.update("[yellow]No packs yet.[/yellow] [dim]Quit, run tuieval new-pack <name>, "
808
823
  "and open tuieval again.[/dim]")
@@ -852,6 +867,11 @@ class SetupScreen(Screen):
852
867
  resumed = [j for j in run if "done earlier" in j.note]
853
868
  if resumed:
854
869
  text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
870
+ at_once = min(self.parallel() or e.parallel_models(), len({j.label for j in run}))
871
+ if at_once > 1:
872
+ text += (f"\n[cyan]Up to {at_once} models at a time[/cyan][dim] (the time above assumes one at a "
873
+ "time). Answers note the models they ran alongside, since those share the machine's "
874
+ "speed.[/dim]")
855
875
  est.update(text + self.queue_hint())
856
876
 
857
877
  def queue_hint(self):
@@ -879,6 +899,8 @@ class SetupScreen(Screen):
879
899
  if name in e.packs:
880
900
  packs.select(name)
881
901
  self.query_one("#repeat", Input).value = str(sel["repeat"]) if sel.get("repeat") else ""
902
+ if "parallel" in sel:
903
+ self.query_one("#parallel", Input).value = str(sel["parallel"]) if sel["parallel"] else ""
882
904
  tier = sel.get("tier") or ("smoke" if sel.get("smoke") else None)
883
905
  if tier in engine.TIERS:
884
906
  self.query_one(f"#tier-{tier}", RadioButton).value = True
@@ -917,7 +939,8 @@ class SetupScreen(Screen):
917
939
  self.notify("Select at least one eval pack and one model.", severity="warning")
918
940
  return
919
941
  picks = self.current_picks(suites)
920
- sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks}
942
+ sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
943
+ "parallel": self.parallel()}
921
944
  jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
922
945
  rerun = False
923
946
  if not any(j.status == "waiting" for j in jobs):
@@ -1453,7 +1476,7 @@ class QueuedRun:
1453
1476
  if new:
1454
1477
  return None, (f"it would now start over {', '.join(new)}, which wasn't confirmed when it was queued. "
1455
1478
  "Start it again from Setup to see why")
1456
- return RunScreen(jobs), None
1479
+ return RunScreen(jobs, sel.get("parallel")), None
1457
1480
 
1458
1481
 
1459
1482
  class QueuedTune:
@@ -1783,6 +1806,7 @@ class RunScreen(Screen):
1783
1806
  Binding("k", "skip_model", "Skip model", priority=True),
1784
1807
  Binding("c", "cancel_all", "Cancel all", priority=True),
1785
1808
  Binding("f", "toggle_follow", "Pause scroll", priority=True),
1809
+ Binding("v", "watch_next", "Next model", priority=True),
1786
1810
  Binding("n", "new_run", "New run", priority=True),
1787
1811
  Binding("r", "results", "Results", priority=True),
1788
1812
  Binding("w", "runs", "Runs", priority=True),
@@ -1793,9 +1817,11 @@ class RunScreen(Screen):
1793
1817
  LIVE_ONLY = {"skip_model", "cancel_all", "toggle_follow"}
1794
1818
  DONE_ONLY = {"new_run"}
1795
1819
 
1796
- def __init__(self, jobs):
1820
+ def __init__(self, jobs, parallel=None):
1797
1821
  super().__init__()
1798
1822
  self.jobs = jobs
1823
+ self.parallel = parallel # models at a time (None: this machine's parallel_models)
1824
+ self.watch = None # the model whose answers stream (several run at a time)
1799
1825
  self.running = True
1800
1826
  self.follow = True
1801
1827
  self.buf = {"reasoning": [], "answer": []}
@@ -1839,12 +1865,24 @@ class RunScreen(Screen):
1839
1865
  self.notify("\n".join(lines), title=title, severity="error", timeout=30)
1840
1866
 
1841
1867
  def check_action(self, action, parameters):
1868
+ if action == "watch_next":
1869
+ return self.running and self.side_by_side
1842
1870
  if action in self.LIVE_ONLY:
1843
1871
  return self.running
1844
1872
  if action in self.DONE_ONLY:
1845
1873
  return not self.running
1846
1874
  return True
1847
1875
 
1876
+ @property
1877
+ def side_by_side(self):
1878
+ """Several models run at a time in this run."""
1879
+ return min(self.parallel or self.app.engine.parallel_models(),
1880
+ len({j.label for j in self.jobs if j.status != "skipped"})) > 1
1881
+
1882
+ def active(self):
1883
+ """Models being loaded or run now, in queue order."""
1884
+ return list(dict.fromkeys(j.label for j in self.jobs if j.status in ("loading", "running")))
1885
+
1848
1886
  def counts(self):
1849
1887
  return (sum(j.done for j in self.jobs), sum(j.passed for j in self.jobs), sum(j.failed for j in self.jobs))
1850
1888
 
@@ -1908,14 +1946,18 @@ class RunScreen(Screen):
1908
1946
  self.query_one("#bar", ProgressBar).update(total=max(self.total, 1), progress=0)
1909
1947
  self.set_interval(0.1, self.flush_streams)
1910
1948
  self.set_interval(1.0, self.tick)
1911
- self.app.start_run(self.jobs, self.on_engine_event)
1949
+ self.app.start_run(self.jobs, self.on_engine_event, self.parallel)
1912
1950
  self.tick()
1913
1951
 
1914
1952
  # -- events from the engine thread
1915
1953
  def on_engine_event(self, kind, **d):
1916
1954
  if self.app.closing:
1917
1955
  return # the app is shutting down; the UI thread may be waiting on the engine
1956
+ if kind in ("request_started", "model_loading") and self.watch not in self.active():
1957
+ self.watch = d["job"].label if "job" in d else d["label"] # follow the next model streaming
1918
1958
  if kind == "delta": # very frequent: buffer, flushed every 100 ms on the UI thread
1959
+ if d.get("label", self.watch) != self.watch:
1960
+ return # another model running alongside: v switches to it
1919
1961
  with self.buf_lock:
1920
1962
  self.buf[d["stream"]].append(d["text"])
1921
1963
  return
@@ -1936,8 +1978,15 @@ class RunScreen(Screen):
1936
1978
  def event(self, text):
1937
1979
  self.query_one("#events", Log).write_line(time.strftime("%H:%M:%S ") + text)
1938
1980
 
1981
+ def watched(self, kind, d):
1982
+ """Whether an event belongs to the streaming model (always, with one model at a time)."""
1983
+ label = d["job"].label if "job" in d else d.get("label")
1984
+ return label is None or label == self.watch or not self.side_by_side
1985
+
1939
1986
  def handle(self, kind, d):
1940
- if kind == "request_started":
1987
+ if kind == "request_started" and not self.watched(kind, d):
1988
+ pass # another model running alongside: its rows still show in Recent results
1989
+ elif kind == "request_started":
1941
1990
  self.flush_streams()
1942
1991
  self.query_one("#reasoning", StreamView).clear()
1943
1992
  self.query_one("#answer", StreamView).clear()
@@ -1948,7 +1997,8 @@ class RunScreen(Screen):
1948
1997
  f"{' [magenta]🖼 image[/magenta]' if d['has_image'] else ''}\n"
1949
1998
  f"{diff_badge(d.get('difficulty', 'unrated'))} {d['test']}")
1950
1999
  elif kind == "request_done":
1951
- self.flush_streams()
2000
+ if self.watched(kind, d):
2001
+ self.flush_streams()
1952
2002
  job, r = d["job"], d["record"]
1953
2003
  mark = "[green]✓[/green]" if r["pass"] else "[red]✗[/red]"
1954
2004
  recent = self.query_one("#recent", DataTable)
@@ -1974,7 +2024,8 @@ class RunScreen(Screen):
1974
2024
  recent.move_cursor(row=recent.row_count - 1)
1975
2025
  if job:
1976
2026
  self.update_job(job)
1977
- self.req_started = None
2027
+ if self.watched(kind, d):
2028
+ self.req_started = None
1978
2029
  self.update_overall()
1979
2030
  elif kind in ("job_started", "job_update", "job_done"):
1980
2031
  job = d["job"]
@@ -2008,15 +2059,18 @@ class RunScreen(Screen):
2008
2059
  self.current = "The other window finished; starting…"
2009
2060
  self.event("the machine is free; starting")
2010
2061
  elif kind == "model_loading":
2011
- self.current = f"Loading [b]{d['label']}[/b]…"
2012
2062
  self.event(f"{d['label']}: starting server")
2013
2063
  if d.get("log"):
2014
2064
  self.server_logs[d["label"]] = d["log"]
2015
- self.query_one("#serverlog", Log).write_line(f"$ {' '.join(d['command'])}")
2016
- self.query_one("#reasoning", StreamView).clear()
2017
- self.query_one("#answer", StreamView).clear()
2065
+ self.query_one("#serverlog", Log).write_line(self.log_prefix(d) + f"$ {' '.join(d['command'])}")
2066
+ if self.watched(kind, d):
2067
+ self.current = f"Loading [b]{d['label']}[/b]…"
2068
+ self.query_one("#reasoning", StreamView).clear()
2069
+ self.query_one("#answer", StreamView).clear()
2070
+ elif kind == "model_waiting":
2071
+ self.event(f"[yellow]{d['message']}[/yellow]")
2018
2072
  elif kind == "server_log":
2019
- self.query_one("#serverlog", Log).write_line(d["line"])
2073
+ self.query_one("#serverlog", Log).write_line(self.log_prefix(d) + d["line"])
2020
2074
  elif kind == "server_stall":
2021
2075
  self.event(f"[bold red]STALL[/bold red] {d['message']}")
2022
2076
  self.notify(d["message"], severity="error", timeout=30)
@@ -2029,7 +2083,8 @@ class RunScreen(Screen):
2029
2083
  elif kind == "model_ready":
2030
2084
  loaded = f" in {d['load_s']:.0f}s" if d.get("load_s") else ""
2031
2085
  self.event(f"{d['label']}: ready as {d['ids']}{loaded}")
2032
- self.current = f"[b]{d['label']}[/b] ready{loaded}, starting evals…"
2086
+ if self.watched(kind, d):
2087
+ self.current = f"[b]{d['label']}[/b] ready{loaded}, starting evals…"
2033
2088
  elif kind == "resource":
2034
2089
  self.mem[d["label"]] = d["rss_mb"]
2035
2090
  elif kind == "server_retry":
@@ -2082,6 +2137,9 @@ class RunScreen(Screen):
2082
2137
  self.app.session_finished(self)
2083
2138
  self.refresh_current()
2084
2139
 
2140
+ def log_prefix(self, d):
2141
+ return f"[{d['label']}] " if self.side_by_side and d.get("label") else ""
2142
+
2085
2143
  def update_job(self, j):
2086
2144
  q = self.query_one("#queue", DataTable)
2087
2145
  style = STATUS_STYLE.get(j.status, "")
@@ -2115,7 +2173,9 @@ class RunScreen(Screen):
2115
2173
  speed += f" · {sorted(tps)[len(tps) // 2]:.0f} tok/s"
2116
2174
  if ttfts:
2117
2175
  speed += f" · TTFT {sorted(ttfts)[len(ttfts) // 2]:.1f}s"
2118
- mem = max(self.mem.values()) if self.mem else 0
2176
+ active = self.active()
2177
+ mem = (sum(self.mem.get(l, 0) for l in active) if self.side_by_side and active # models side by side add up
2178
+ else max(self.mem.values()) if self.mem else 0)
2119
2179
  if mem:
2120
2180
  speed += f" · mem {mem / 1024:.1f} GB" if mem >= 1024 else f" · mem {mem:.0f} MB"
2121
2181
  self.query_one("#overall", Static).update(
@@ -2132,6 +2192,12 @@ class RunScreen(Screen):
2132
2192
  if self.req_first and now - self.req_first > 1:
2133
2193
  rate = f" · ~{self.req_tokens / (now - self.req_first):.0f} tok/s"
2134
2194
  extra = f" [dim]{fmt_secs(now - self.req_started)} · {ttft}~{self.req_tokens} tokens{rate}[/dim]"
2195
+ others = [l for l in self.active() if l != self.watch] if self.running and self.side_by_side else []
2196
+ if others:
2197
+ def where(label):
2198
+ j = next((j for j in self.jobs if j.label == label and j.status in ("loading", "running")), None)
2199
+ return f"{label} ({j.pack.label} {j.done}/{j.total})" if j and j.status == "running" else f"{label} (loading)"
2200
+ extra += f"\n[dim]Also running: {' · '.join(map(where, others))} · v streams the next one[/dim]"
2135
2201
  self.query_one("#current", Static).update(self.current + extra)
2136
2202
 
2137
2203
  def tick(self):
@@ -2144,8 +2210,28 @@ class RunScreen(Screen):
2144
2210
  # -- actions
2145
2211
  def action_skip_model(self):
2146
2212
  if self.running:
2147
- self.app.engine.skip_model()
2148
- self.notify("Skipping the current model…")
2213
+ if self.side_by_side and self.watch:
2214
+ self.app.engine.skip_model(self.watch)
2215
+ self.notify(f"Skipping {self.watch}; the others keep going…")
2216
+ else:
2217
+ self.app.engine.skip_model()
2218
+ self.notify("Skipping the current model…")
2219
+
2220
+ def action_watch_next(self):
2221
+ """Stream the next model running alongside (from its next answer)."""
2222
+ active = self.active()
2223
+ if len(active) < 2:
2224
+ self.notify("Only one model is running right now.")
2225
+ return
2226
+ self.watch = active[(active.index(self.watch) + 1) % len(active)] if self.watch in active else active[0]
2227
+ with self.buf_lock:
2228
+ for v in self.buf.values():
2229
+ v.clear()
2230
+ self.query_one("#reasoning", StreamView).clear()
2231
+ self.query_one("#answer", StreamView).clear()
2232
+ self.req_started = None
2233
+ self.current = f"Streaming [b]{self.watch}[/b] from its next answer…"
2234
+ self.refresh_current()
2149
2235
 
2150
2236
  def action_cancel_all(self):
2151
2237
  if not self.running:
@@ -2548,6 +2634,11 @@ class ResultsScreen(Screen):
2548
2634
  f"failed; both passed {h['both']}, both failed {h['neither']}. Fewer tokens: {a} on "
2549
2635
  f"{h['fewer_a']}, {b} on {h['fewer_b']}" + (f" (median {a}/{b} ratio {h['ratio']:.2f}×)"
2550
2636
  if h["ratio"] else "") + ".")
2637
+ shared_speed = sorted({c_m for t in shared for c_m, c in t["cells"].items() if c_m in models
2638
+ and any(r.get("alongside") for r in c["rows"])})
2639
+ if shared_speed:
2640
+ line += (f"\n[yellow]Some answers of {', '.join(shared_speed)} ran alongside other models, which "
2641
+ "shared the machine's speed: compare their times with care.[/yellow]")
2551
2642
  title.update(line + " [dim]Fewest tokens / Fastest = questions won; ties count for both. Total tokens / "
2552
2643
  "Total time add up each question's median. Avg tok/s = all tokens over all the time they took "
2553
2644
  "(cached prompts left out).[/dim]")
@@ -2676,6 +2767,7 @@ class EvalsApp(App):
2676
2767
  #options { height: 3; padding: 0 1; align-vertical: middle; }
2677
2768
  #options Label { padding: 1 1 0 0; }
2678
2769
  #options Input { width: 12; }
2770
+ #options #parallel { width: 8; }
2679
2771
  #estimate { padding: 0 2; height: auto; min-height: 2; }
2680
2772
  #buttons { height: 3; padding: 0 1; }
2681
2773
  #buttons Button { margin-right: 2; }
@@ -2965,10 +3057,10 @@ class EvalsApp(App):
2965
3057
  self.worker = threading.Thread(target=work, name="tune")
2966
3058
  self.worker.start()
2967
3059
 
2968
- def start_run(self, jobs, on_event):
3060
+ def start_run(self, jobs, on_event, parallel=None):
2969
3061
  self.engine.on_event = on_event
2970
3062
  # A plain (non-daemon) thread, so quitting can wait for servers to be stopped.
2971
- self.worker = threading.Thread(target=self.engine.run, args=(jobs,), name="engine")
3063
+ self.worker = threading.Thread(target=self.engine.run, args=(jobs, parallel), name="engine")
2972
3064
  self.worker.start()
2973
3065
 
2974
3066
  def on_unmount(self):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev23
3
+ Version: 0.2.0.dev28
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,22 +1,22 @@
1
1
  tuieval/__init__.py,sha256=myVXVX7htbwezW6k3x_FuKgn1RSZ2WcTaDEMByGn6Wg,312
2
2
  tuieval/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
- tuieval/_version.py,sha256=hrYSfDsgWa6h-shaGODbtlY3KfUtC003qRSAak6fmuw,535
3
+ tuieval/_version.py,sha256=mCD5WpGBJi5o6Jnxl_I1TiLi_4xG9de502jCBwE897Y,535
4
4
  tuieval/builtin.toml,sha256=Kxw22RZCJhURUyu5Wz8tj5Y1nM85A8hFVRbA4ocoPUg,4293
5
5
  tuieval/cli.py,sha256=7joEA07W2we-kcg4VXxfCSXazj7dWBCKlQlSXpXQfTc,4390
6
6
  tuieval/client.py,sha256=Vui7ucVv_GiKObGoVS_EWSZFUKPou9PWmutqMTrahXk,9313
7
- tuieval/compare.py,sha256=b-rVcMwq0Tos3oNKcpUP1HXULKhAQNAS4rlXYSY7t6k,25460
7
+ tuieval/compare.py,sha256=SHN_o_nlppFcHMBqBZRITIkeQDPaR_JxhUZItQLMBwo,26270
8
8
  tuieval/doctor.py,sha256=y1C_ggjd8oikXTrovgTBf26wjThP8EoVkfTVeO_n5dQ,6070
9
- tuieval/engine.py,sha256=aRZn-PgB8e4DCLJNPsMhIsF6Kk-CHB_CIsP2ECRizy0,103183
9
+ tuieval/engine.py,sha256=k9DtWDJLfTXSPMaIR4qw-0SadTD0ts5hb3Uv38oTY4g,111278
10
10
  tuieval/export.py,sha256=dbHQgL_ubWtIPM1sLzAew_gETnrDJyd0zgqVA5vkpXM,9657
11
11
  tuieval/machines.py,sha256=ImEihNj8QlBRdCHLED4QIdjJUvjVtZgo6DiVU5h6Zzs,13415
12
12
  tuieval/onboard.py,sha256=IYETVDWCCo9vF0Dtj4_rr9VanYeCky8PwP3-2g5A_l0,6215
13
13
  tuieval/packs.py,sha256=tiLmxsrX_M5EUMuR4Yvb7pLBLEI15wamHSjflD6wjoY,11299
14
14
  tuieval/profiles.py,sha256=soeLxjdB-l-OxSY8jZ4yjwfdSnsBV-QICcwKxRJS7nQ,3663
15
15
  tuieval/remove.py,sha256=Vwh43nm9lVb9U9aN-h6CMYbkTwdR3UWfZObwweF72n0,5157
16
- tuieval/run_evals.py,sha256=nGkbMj0qE4G4RSgIK1lradOFD_8weITk0w54TRNxqI0,42426
16
+ tuieval/run_evals.py,sha256=ZGx4XcZ15qD3vIg15xCfdX4SxTDHXBp_GYoiGexchdw,43668
17
17
  tuieval/scaffold.py,sha256=YBOqniJpf_GtKKAFF3_OHxxMGbmTWdFjcClGcOnrrKE,12130
18
18
  tuieval/selftest.py,sha256=nExkrOidgymlmvkxImEBfrQG5iTUN5WuRMIKtqusCCs,9835
19
- tuieval/tui.py,sha256=aG-KMsD_8ma27fsHvw01S7V_g10pzJ-nrWi5pq28DA8,151175
19
+ tuieval/tui.py,sha256=pMH15LkHEeIdr8GlB55Znxdy4OuTFDtNTkGrp7ItlvI,156704
20
20
  tuieval/tune.py,sha256=jXwYuSjfdqbLCE3WonP2xaUHwk_FnhI67twB1BaHggI,38992
21
21
  tuieval/verdict.py,sha256=Muo1a05mCv781MlEJsXwDsGaKbRi0JS4Lv8HdvK_Aco,19054
22
22
  tuieval/watch_proxy.py,sha256=z916NRhEur04a9RnLm6ZpIEY1SnnUGPiHYbmlD9ktQ0,11035
@@ -24,7 +24,7 @@ tuieval/workspace.py,sha256=V_HhkaN1odr0OpjNsPYradRWLpzDldM7SwpvxohBL4U,895
24
24
  tuieval/yamlout.py,sha256=r1IokjLqW-Ztpg1fSn33ZW3VK00v1VvxymmU5Qy7GEw,606
25
25
  tuieval/graders/__init__.py,sha256=_YCFY5euaInIQdX4RoQGS3kMuiK6q5J7HF2e4i0ou3Q,5940
26
26
  tuieval/graders/answer.py,sha256=pTETs2LzOWPhtuuowMVXpLFwdnNjMw-7sELqrFV6ocQ,2125
27
- tuieval/graders/code.py,sha256=mNoPw-C_lNaXTTU57TJjhcVPTvdR400PxoE9Ow-tgHg,3703
27
+ tuieval/graders/code.py,sha256=Q97J0pRhg4xPlhLJoF1theV3vAECJB8jkM6JQMZnIes,3796
28
28
  tuieval/graders/rag.py,sha256=j_gEjmaXmY_hgKu7-7pVQpQ8Cwb49gadnwFurih-1Hw,1447
29
29
  tuieval/graders/reply.py,sha256=BdkL7EWSSMoyjwCOFa97TTQMR1QyrpMH-ntcgjzDPEg,2199
30
30
  tuieval/graders/tool_call.py,sha256=_QXURtOk4ZKBnxXVMzFL1c9jWLWksRlLZ4VqOCL0t90,3578
@@ -45,8 +45,8 @@ tuieval/templates/packs/tool_call/pack.toml,sha256=n4bx0lGJHSmW41uVqhfIXJ2BWZpmT
45
45
  tuieval/templates/packs/tool_call/system.txt,sha256=mKxu-lB-0YjLHXdgeFJa8x2C180OvcNz5cHv2N5Bg3U,254
46
46
  tuieval/templates/packs/tool_call/tests.yaml,sha256=Nv7c4syrzJ9cU8EBKSK_1ZSFx9V02aCjW4-2TBCPT7s,1859
47
47
  tuieval/templates/packs/tool_call/tools.yaml,sha256=HgxCkh0On2VwbHSsjAGM8tNCK7ZtKLGrs7lMCn3Fprs,1048
48
- tuieval-0.2.0.dev23.dist-info/METADATA,sha256=fE4RF7BtlfvEDdZkR8sgZCUM7jpl9S0TXgFZg2076A4,13227
49
- tuieval-0.2.0.dev23.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
- tuieval-0.2.0.dev23.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
51
- tuieval-0.2.0.dev23.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
52
- tuieval-0.2.0.dev23.dist-info/RECORD,,
48
+ tuieval-0.2.0.dev28.dist-info/METADATA,sha256=rVlRwOpwJXIudx4BD9v-tcTnH4RMb4SqOhtnLut5Xos,13227
49
+ tuieval-0.2.0.dev28.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
+ tuieval-0.2.0.dev28.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
51
+ tuieval-0.2.0.dev28.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
52
+ tuieval-0.2.0.dev28.dist-info/RECORD,,