tuieval 0.2.0.dev11__py3-none-any.whl → 0.2.0.dev23__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.dev11'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev11')
21
+ __version__ = version = '0.2.0.dev23'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev23')
23
23
 
24
24
  __commit_id__ = commit_id = None
tuieval/builtin.toml ADDED
@@ -0,0 +1,83 @@
1
+ # Built-in defaults and servers, used when a workspace's models.toml doesn't define them.
2
+ # [defaults] fill in key by key; [sampling] only when models.toml has no [sampling] at all (it's part of
3
+ # every result's fingerprint); a server here is replaced whole by one of the same name in models.toml.
4
+
5
+ [defaults]
6
+ repeat = 3
7
+ request_timeout_ms = 1800000 # per request; long thinking runs need it
8
+ ready_timeout_s = 1200 # how long a model may take to load before giving up
9
+ model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
10
+
11
+ # Sent with every request. A model may override it (see `thinking` / `sampling` below); results
12
+ # record the settings each model actually ran with, and the scorecard shows any difference.
13
+ [sampling]
14
+ temperature = 0.7
15
+ top_p = 0.95
16
+ top_k = 20
17
+ min_p = 0.0
18
+ max_tokens = 16384
19
+ enable_thinking = true # test every model in the SAME mode
20
+
21
+ # llama.cpp's server (https://github.com/ggml-org/llama.cpp), started for each GGUF model.
22
+ [servers.llama]
23
+ # Output-affecting flags only. Anything here must be identical wherever results are compared,
24
+ # so it's part of each result's fingerprint. Speed-only flags live in `perf` and `tune` below.
25
+ cmd = [
26
+ "llama-server",
27
+ "-m", "{model}",
28
+ "--alias", "{served_name}",
29
+ "--host", "127.0.0.1", "--port", "{port}",
30
+ "--jinja",
31
+ "--metrics",
32
+ "-np", "1",
33
+ "-ctk", "{kv_type}", "-ctv", "{kv_type}",
34
+ "-c", "{ctx}",
35
+ "--reasoning-format", "auto",
36
+ ]
37
+ port = 8080
38
+ model_is_path = true # check the GGUF exists and fits before starting
39
+ vision_args = ["--mmproj", "{mmproj}"] # appended only for models that set mmproj
40
+ kv_type = "f16" # a model's own `kv_type` (e.g. "q8_0") makes it a different model entry
41
+ max_ctx = 65536 # upper bound; the fit check lowers it per machine (see machines.py)
42
+ version_cmd = ["llama-server", "--version"] # recorded with every result
43
+ # Added to every request. cache_prompt = false: each answer is computed from scratch, so a repeat of
44
+ # the same question reuses nothing from the last time it was asked (and its TTFT is honest).
45
+ # Answers record any prompt tokens the server reused.
46
+ request = { cache_prompt = false }
47
+
48
+ # Speed-only flags, always applied (not tuned).
49
+ perf = ["-ngl", "99"]
50
+
51
+ # Speed-only knobs `tuieval tune` tries, one at a time from the best so far. The first option of
52
+ # each knob is the default for models not tuned on this machine yet. Placeholders: {p} P-cores,
53
+ # {p_minus_2}, {all} all cores. Options that stop the server from starting are skipped.
54
+ # Check the flags against `llama-server --help` for your build.
55
+ [servers.llama.tune]
56
+ threads = [["-t", "{p}", "-tb", "{p}"], ["-t", "{p_minus_2}", "-tb", "{p}"], ["-t", "{all}", "-tb", "{all}"]]
57
+ ubatch = [["-ub", "512"], ["-ub", "256"], ["-ub", "1024"]]
58
+ batch = [["-b", "2048"], ["-b", "4096"]]
59
+ flash_attn = [["-fa", "on"], ["-fa", "off"]]
60
+
61
+ # A server that's already running (LM Studio, Ollama, vLLM, a remote box): give its URL and no cmd.
62
+ # Its models are added with `tuieval add <model id> --server local`.
63
+ [servers.local]
64
+ url = "http://127.0.0.1:1234" # LM Studio's default; Ollama is http://127.0.0.1:11434, vLLM :8000
65
+
66
+ # OpenRouter: ANY model it serves, with no [[models]] entry. Name it when you run:
67
+ # tuieval run --tier smoke --only openrouter:qwen/qwen3-235b-a22b-2507
68
+ # TUI: tick "+ openrouter: any model" in the model list, or press "a" and type openrouter:<model id>
69
+ # Results go to results/or-<id>/ and the model stays listed once it has results. A plain
70
+ # `tuieval run` never includes OpenRouter models (they cost money); name them.
71
+ # Needs OPENROUTER_API_KEY in the environment of the shell that starts tuieval.
72
+ # pin_endpoint: every answer of a model comes from one provider endpoint (the closest to the released
73
+ # weights), with no fallback, since providers run different quantizations.
74
+ # To keep providers that store or train on prompts (and could learn your tests) out entirely, add
75
+ # request = { provider = { data_collection = "deny" } }
76
+ [servers.openrouter]
77
+ url = "https://openrouter.ai/api/v1"
78
+ any_model = true
79
+ label_prefix = "or"
80
+ pin_endpoint = true
81
+ api_key_env = "OPENROUTER_API_KEY"
82
+ thinking_param = "reasoning" # [sampling] enable_thinking -> OpenRouter's reasoning.enabled
83
+ headers = { "X-Title" = "tuieval" }
tuieval/cli.py CHANGED
@@ -2,28 +2,33 @@
2
2
 
3
3
  Usage: tuieval [--workspace DIR] [command] [options]
4
4
 
5
- tuieval open the TUI
6
- tuieval init [DIR] create a workspace (models.toml, packs/, graders/)
7
- tuieval new-pack NAME a new pack from a starter template (--grader answer|rag|reply|tool_call|code)
8
- tuieval add PATH|ID register a model in models.toml (a GGUF, or a model id)
9
- tuieval scan list GGUFs in model_dirs that aren't in models.toml yet
10
- tuieval list the models tuieval knows, hidden ones separately (--packs: the packs)
11
- tuieval remove MODEL remove models, or every hidden one (--hidden); archived in removed/
5
+ Start here
6
+ tuieval init [DIR] create a workspace; in a terminal, a guided setup to a first result
7
+ tuieval open the TUI (? explains any screen)
8
+ tuieval doctor check servers, models, packs and API keys, and say what to fix
9
+ tuieval add [PATH|ID] add a model: a GGUF, a model id, or none to pick from running servers
10
+ tuieval new-pack NAME a new pack of example questions (--from CSV, --about TOPIC, --grader …)
11
+ tuieval selftest check every test's reference and wrong answers against its grader
12
12
  tuieval run [...] run evals from the command line (tuieval run --help)
13
+ tuieval verdict which model is ready for what, and what to run next
14
+
15
+ Day to day
13
16
  tuieval compare [...] scorecards (--speed, --pairwise, --failures)
14
- tuieval regrade re-score stored answers after changing a grader
15
- tuieval verdict production readiness (PASS/FAIL per model and use case)
16
- tuieval report the readiness verdicts with their evidence, as a markdown file
17
+ tuieval report the verdicts with their evidence, as a markdown file
17
18
  tuieval history every verdict change, newest first
18
- tuieval selftest check every test's reference and wrong answers against its grader
19
19
  tuieval items tests that don't separate models or look broken
20
20
  tuieval capture LOG --pack turn a real failure (logs/live/*.txt) into a new test
21
+ tuieval regrade re-score stored answers after changing a grader
22
+ tuieval list the models tuieval knows, hidden ones separately (--packs: the packs)
23
+ tuieval remove MODEL remove models, or every hidden one (--hidden); archived in removed/
24
+
25
+ Advanced
26
+ tuieval scan list GGUFs in model_dirs that aren't in models.toml yet
21
27
  tuieval machines this machine, other recorded machines, and fit and tuning per model
22
28
  tuieval tune MODEL find the fastest speed-only server flags for a model on this machine
23
29
  tuieval export pi MODEL serve a model in the pi coding agent with its tuned flags
24
30
  tuieval watch a proxy that shows the reasoning of any app using your server
25
- tuieval help this help
26
- tuieval --version
31
+ tuieval help · --version
27
32
 
28
33
  The workspace is --workspace DIR, else $TUIEVAL_HOME, else the current folder.
29
34
  """
@@ -67,6 +72,9 @@ def main(argv=None):
67
72
  if not workspace.is_workspace():
68
73
  sys.exit(f"{workspace.root()} isn't a tuieval workspace; run `tuieval init` first")
69
74
  return scaffold.cmd_new_pack(rest)
75
+ if cmd == "doctor":
76
+ from . import doctor
77
+ return doctor.cmd_doctor(rest)
70
78
  if cmd == "run":
71
79
  from . import run_evals
72
80
  return run_evals.main(rest)
tuieval/doctor.py ADDED
@@ -0,0 +1,131 @@
1
+ """tuieval doctor: checks the setup and says what to fix (servers, models, packs, API keys).
2
+
3
+ tuieval doctor
4
+
5
+ Each line is ✓ (fine), ! (worth knowing) or ✗ (a run would fail), with the fix. Exits 1 if any ✗.
6
+ """
7
+ import argparse
8
+ import os
9
+ import platform
10
+ import sys
11
+
12
+ from . import engine
13
+ from . import workspace
14
+
15
+ OK, WARN, BAD = "✓", "!", "✗"
16
+ COLOR = {OK: "\033[32m", WARN: "\033[33m", BAD: "\033[31m"}
17
+
18
+
19
+ class Report:
20
+ def __init__(self):
21
+ self.bad = 0
22
+ self.color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
23
+
24
+ def line(self, mark, text, fix=""):
25
+ self.bad += mark == BAD
26
+ m = f"{COLOR[mark]}{mark}\033[0m" if self.color else mark
27
+ print(f" {m} {text}" + (f"\n → {fix}" if fix else ""))
28
+
29
+ def head(self, text):
30
+ print(f"\n{text}")
31
+
32
+
33
+ def check(models_path=None):
34
+ """Print the report; returns the number of problems that would make a run fail."""
35
+ r = Report()
36
+ r.head("tuieval")
37
+ py = platform.python_version()
38
+ r.line(OK if sys.version_info >= (3, 11) else BAD, f"Python {py}", "" if sys.version_info >= (3, 11)
39
+ else "tuieval needs Python 3.11 or newer")
40
+ try:
41
+ e = engine.Engine(models_path=models_path)
42
+ except Exception as ex: # a broken models.toml: nothing else can be checked
43
+ r.line(BAD, f"models.toml in {workspace.root()}: {ex}", "fix the file (tuieval init makes a fresh one elsewhere)")
44
+ return r.bad
45
+ r.line(OK, f"workspace {e.root}")
46
+
47
+ r.head("Packs")
48
+ for err in e.pack_errors:
49
+ r.line(BAD, err, "fix the pack, then: tuieval selftest")
50
+ if e.packs:
51
+ tests = sum(len(p.tests) for p in e.packs.values())
52
+ r.line(OK, f"{len(e.packs)} pack(s), {tests} tests (tuieval selftest checks their answers)")
53
+ elif not e.pack_errors:
54
+ r.line(BAD, "no packs yet", "tuieval new-pack my-first-pack (a pack of example questions to edit)")
55
+
56
+ r.head("Servers")
57
+ found = engine.detect_servers(engine.detect_ports(e.cfg))
58
+ used = {m["server"] for m in e.cfg["models"]}
59
+ answering = {}
60
+ for name, server in e.cfg["servers"].items():
61
+ if server.get("any_model") or server.get("api_key_env"):
62
+ key = server.get("api_key_env")
63
+ if key and os.environ.get(key):
64
+ r.line(OK, f"{name}: {key} is set")
65
+ elif key:
66
+ r.line(WARN, f"{name}: {key} isn't set (needed only for {name} models)",
67
+ f"export {key}=… in the shell you start tuieval from")
68
+ continue
69
+ if server.get("cmd"):
70
+ missing = engine.missing_program([str(a) for a in server["cmd"]],
71
+ engine.expand(server["cwd"]) if server.get("cwd") else None)
72
+ if missing:
73
+ r.line(BAD if name in used else WARN,
74
+ f"{name}: {missing} isn't installed" + ("" if name in used else " (no model uses it yet)"),
75
+ engine.install_hint(missing))
76
+ else:
77
+ r.line(OK, f"{name}: {server['cmd'][0]} is installed; tuieval starts it for each model")
78
+ continue
79
+ url = server.get("url", "").rstrip("/").removesuffix("/v1")
80
+ ids = engine.probe_models(url) if url else None
81
+ answering[name] = ids
82
+ if ids is not None:
83
+ r.line(OK, f"{name}: {url} answers, serving {len(ids)} model(s)" + (f": {', '.join(ids[:5])}" if ids else ""))
84
+ else:
85
+ elsewhere = [f for f in found if f["url"] != url]
86
+ fix = (f"start the server at {url}, or "
87
+ + ("run `tuieval add` to add the models of the one running at " + ", ".join(
88
+ f"{f['url']}" + (f" ({f['usual']})" if f["usual"] else "") for f in elsewhere)
89
+ if elsewhere else engine.url_fix(e.cfg, name)))
90
+ r.line(BAD if name in used else WARN,
91
+ f"{name}: nothing answers at {url}" + ("" if name in used else " (no model uses it yet)"), fix)
92
+ known = {s.get("url", "").rstrip("/").removesuffix("/v1") for s in e.cfg["servers"].values()}
93
+ for f in found:
94
+ if f["url"] not in known:
95
+ r.line(WARN, f"found a server at {f['url']}" + (f" ({f['usual']}'s usual port)" if f["usual"] else "")
96
+ + f" serving {len(f['models'])} model(s), not in models.toml",
97
+ "add its models with: tuieval add")
98
+
99
+ r.head("Models")
100
+ if not e.cfg["models"]:
101
+ r.line(BAD, "no models yet", "tuieval add <GGUF path or model id>, or press a in the TUI")
102
+ for m in e.cfg["models"]:
103
+ server = e.cfg["servers"][m["server"]]
104
+ label = f"{m['label']} ({m['server']})"
105
+ if server.get("model_is_path"):
106
+ path = engine.expand(m["model"])
107
+ r.line(OK if os.path.exists(path) else BAD, f"{label}: {path}",
108
+ "" if os.path.exists(path) else "the file is gone; fix `model` in models.toml or tuieval remove it")
109
+ elif m["server"] in answering:
110
+ ids = answering[m["server"]]
111
+ if ids is None:
112
+ r.line(BAD, f"{label}: its server isn't running", "see Servers above")
113
+ elif engine.serves(ids, m["served_name"]):
114
+ r.line(OK, f"{label}: served")
115
+ else:
116
+ r.line(BAD, f"{label}: the server doesn't list {m['served_name']!r}",
117
+ f"load it in the server, or use one of: {', '.join(ids[:8]) or 'none listed'}")
118
+ else:
119
+ r.line(OK, label)
120
+
121
+ print("\n" + ("Ready: open the TUI with `tuieval`, or run `tuieval run --tier smoke`." if not r.bad
122
+ else f"{r.bad} problem(s) to fix before a run (✗ above)."))
123
+ return r.bad
124
+
125
+
126
+ def cmd_doctor(argv):
127
+ p = argparse.ArgumentParser(prog="tuieval doctor", description=__doc__,
128
+ formatter_class=argparse.RawDescriptionHelpFormatter)
129
+ p.add_argument("--models", default=None, help="default: the workspace's models.toml")
130
+ a = p.parse_args(argv)
131
+ sys.exit(1 if check(a.models) else 0)
tuieval/engine.py CHANGED
@@ -38,6 +38,7 @@ import mimetypes
38
38
  import os
39
39
  import random
40
40
  import re
41
+ import shutil
41
42
  import signal
42
43
  import socket
43
44
  import statistics
@@ -72,10 +73,26 @@ def slug(model):
72
73
  return re.sub(r"[^a-z0-9._-]+", "-", name.lower()).strip("-.")
73
74
 
74
75
 
76
+ BUILTIN_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "builtin.toml")
77
+
78
+
79
+ def builtin_config():
80
+ with open(BUILTIN_PATH, "rb") as f:
81
+ return tomllib.load(f)
82
+
83
+
75
84
  def load_config(path):
76
- """Read models.toml and fill in each model's defaults."""
85
+ """Read models.toml, fill in the built-ins it leaves out (builtin.toml) and each model's defaults.
86
+ [defaults] are filled key by key; [sampling] only when models.toml has none (sampling is part of
87
+ every result's fingerprint, so a workspace's own keys are never added to); servers by name."""
77
88
  with open(path, "rb") as f:
78
89
  cfg = tomllib.load(f)
90
+ builtin = builtin_config()
91
+ cfg["defaults"] = {**builtin["defaults"], **cfg.get("defaults", {})}
92
+ cfg.setdefault("sampling", builtin["sampling"])
93
+ own = cfg.get("servers", {})
94
+ cfg["servers"] = {**builtin["servers"], **own}
95
+ cfg["builtin_servers"] = sorted(set(builtin["servers"]) - set(own)) # not defined in models.toml
79
96
  cfg.setdefault("models", [])
80
97
  seen = set()
81
98
  for m in cfg["models"]:
@@ -218,6 +235,8 @@ def infer_server(cfg, model):
218
235
  is_file = model.lower().endswith(".gguf")
219
236
  choices = [n for n, s in cfg["servers"].items()
220
237
  if bool(s.get("model_is_path")) == is_file and not s.get("any_model")]
238
+ own = [n for n in choices if n not in cfg.get("builtin_servers", ())] # the workspace's own come first
239
+ choices = own or choices
221
240
  return choices[0] if len(choices) == 1 else None
222
241
 
223
242
 
@@ -358,6 +377,7 @@ def load_presets(path):
358
377
  tags = ["moe"] # models with any of these tags …
359
378
  models = ["foo", "bar"] # … and/or these labels (omit both for every model)
360
379
  repeat = 3
380
+ tests = { coding = ["parse-dates"] } # optional: only these tests of a pack
361
381
  """
362
382
  if not os.path.isfile(path):
363
383
  return {}
@@ -366,19 +386,24 @@ def load_presets(path):
366
386
 
367
387
 
368
388
  def resolve_preset(cfg, preset, pack_names):
369
- """(labels, packs, repeat) for a preset; unknown names are dropped."""
389
+ """(labels, packs, repeat, tests) for a preset; unknown names are dropped. tests: {pack: [test
390
+ ids]} for packs where only some tests run."""
370
391
  labels = [m["label"] for m in cfg["models"]]
371
392
  if preset.get("models") or preset.get("tags"):
372
393
  want, tags = set(preset.get("models", [])), set(preset.get("tags", []))
373
394
  labels = [m["label"] for m in cfg["models"] if m["label"] in want or tags & set(m["tags"])]
374
395
  packs = [p for p in preset.get("packs", pack_names) if p in pack_names]
375
- return labels, packs, preset.get("repeat")
396
+ tests = {p: list(ids) for p, ids in (preset.get("tests") or {}).items() if p in packs and ids}
397
+ return labels, packs, preset.get("repeat"), tests
376
398
 
377
399
 
378
- def save_preset(path, name, labels, packs, repeat):
400
+ def save_preset(path, name, labels, packs, repeat, tests=None):
379
401
  """Add or replace one preset, keeping the rest of the file as written."""
402
+ tests = {p: ids for p, ids in (tests or {}).items() if p in packs and ids}
380
403
  block = (f"[{name}]\nmodels = [{', '.join(json.dumps(l) for l in labels)}]\n"
381
- f"packs = [{', '.join(json.dumps(p) for p in packs)}]\n" + (f"repeat = {int(repeat)}\n" if repeat else ""))
404
+ f"packs = [{', '.join(json.dumps(p) for p in packs)}]\n" + (f"repeat = {int(repeat)}\n" if repeat else "")
405
+ + ("tests = { " + ", ".join(f"{json.dumps(p)} = [{', '.join(json.dumps(i) for i in ids)}]"
406
+ for p, ids in tests.items()) + " }\n" if tests else ""))
382
407
  text = open(path).read() if os.path.isfile(path) else \
383
408
  "# Saved selections for the TUI (p) and tuieval run --preset <name>. See engine.load_presets.\n"
384
409
  pattern = re.compile(rf"^\[{re.escape(name)}\]\n(?:(?!\[).*\n?)*", re.MULTILINE)
@@ -394,12 +419,13 @@ class Job:
394
419
  model: dict
395
420
  pack: packs_mod.Pack
396
421
  repeat: int
397
- tests: list # the tests this tier runs
422
+ tests: list # the tests this tier runs (or the ones picked)
398
423
  out_path: str
399
424
  sampling: dict
400
425
  tier: str = "certify"
401
426
  settings: dict = dataclasses.field(default_factory=dict) # sampling + serving identity (fingerprinted)
402
427
  merge: bool = True # keep matching earlier results and only run what's missing
428
+ redo: bool = False # rerun the picked tests, keeping the pack's other answers
403
429
  status: str = "waiting" # waiting, loading, running, done, failed, skipped
404
430
  note: str = ""
405
431
  discards: list = dataclasses.field(default_factory=list) # [(what, answers, why)] set aside on start
@@ -504,6 +530,115 @@ def server_error(log, start):
504
530
  return line.split("]: ", 1)[-1]
505
531
  return line.removeprefix("error: ")
506
532
 
533
+ def missing_program(cmd, cwd=None):
534
+ """The program a server command runs, if it isn't installed (not on PATH, or no such file); else None."""
535
+ exe = next((a for a in cmd if a != "env" and not re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", a)), None)
536
+ if not exe:
537
+ return None
538
+ if "/" in exe:
539
+ return None if os.path.isfile(os.path.join(cwd or "", expand(exe))) else exe
540
+ return None if shutil.which(exe) else exe
541
+
542
+
543
+ def install_hint(program):
544
+ """How to get a server program, for the ones tuieval's templates use."""
545
+ if os.path.basename(program) == "llama-server":
546
+ return ("install llama.cpp (macOS: brew install llama.cpp; others: a release from "
547
+ "https://github.com/ggml-org/llama.cpp/releases) so llama-server is on PATH")
548
+ return "install it, or fix the server's cmd in models.toml"
549
+
550
+
551
+ def probe_models(base_url, timeout=2.0, headers=None):
552
+ """The model ids an OpenAI-compatible server lists at base_url, or None when nothing answers."""
553
+ req = urllib.request.Request(base_url.rstrip("/").removesuffix("/v1") + "/v1/models", headers=headers or {})
554
+ try:
555
+ with urllib.request.urlopen(req, timeout=timeout) as r:
556
+ return [str(x.get("id", "")) for x in json.load(r).get("data", []) if isinstance(x, dict)]
557
+ except (OSError, ValueError, AttributeError):
558
+ return None
559
+
560
+
561
+ # Where local OpenAI-compatible servers usually listen. models.toml [defaults] detect_ports
562
+ # replaces the list (e.g. a server on a custom port, or [] to never look).
563
+ DETECT_PORTS = {1234: "LM Studio", 11434: "Ollama", 8080: "llama-server", 8000: "vLLM"}
564
+
565
+
566
+ SERVER_NAMES = {1234: "lmstudio", 11434: "ollama", 8080: "llamacpp", 8000: "vllm"} # for new [servers.*] entries
567
+
568
+
569
+ def detect_ports(cfg=None):
570
+ """Ports to look for running servers on: $TUIEVAL_DETECT_PORTS (comma-separated, empty = none),
571
+ else models.toml [defaults] detect_ports, else the usual ones."""
572
+ env = os.environ.get("TUIEVAL_DETECT_PORTS")
573
+ if env is not None:
574
+ return [int(p) for p in env.replace(" ", "").split(",") if p]
575
+ return [int(p) for p in ((cfg or {}).get("defaults") or {}).get("detect_ports", list(DETECT_PORTS))]
576
+
577
+
578
+ def _norm_url(url):
579
+ return (url or "").rstrip("/").removesuffix("/v1").replace("localhost", "127.0.0.1")
580
+
581
+
582
+ def server_for_url(cfg, url):
583
+ """The models.toml server already pointing at url (a server tuieval doesn't start), or None."""
584
+ return next((n for n, s in cfg["servers"].items() if not s.get("cmd") and _norm_url(s.get("url")) == _norm_url(url)),
585
+ None)
586
+
587
+
588
+ def ensure_server(models_path, url, port=None):
589
+ """The name of a models.toml server for an already-running server at url, adding a
590
+ [servers.<name>] block when there's none (named after the app that usually uses the port)."""
591
+ cfg = load_config(models_path)
592
+ name = server_for_url(cfg, url)
593
+ if name:
594
+ return name
595
+ base = SERVER_NAMES.get(port, f"local-{port}" if port else "local-server")
596
+ name = base if base not in cfg["servers"] else f"{base}-{port}"
597
+ with open(models_path) as f:
598
+ original = f.read()
599
+ with open(models_path, "w") as f:
600
+ f.write(original.rstrip("\n") + f'\n\n[servers.{name}]\nurl = "{_norm_url(url)}"\n')
601
+ try:
602
+ load_config(models_path)
603
+ except BaseException:
604
+ with open(models_path, "w") as f:
605
+ f.write(original)
606
+ raise
607
+ return name
608
+
609
+
610
+ def url_fix(cfg, name):
611
+ """How to point a server at another URL in models.toml (a built-in one has no section there yet)."""
612
+ if name in cfg.get("builtin_servers", ()):
613
+ return f'add [servers.{name}] with url = "http://127.0.0.1:<port>" to models.toml'
614
+ return f"fix `url` under [servers.{name}] in models.toml"
615
+
616
+
617
+ def find_running(cfg, model):
618
+ """Running servers (detect_servers) that list this model id, best match first."""
619
+ found = detect_servers(detect_ports(cfg))
620
+ exact = [f for f in found if model in f["models"]]
621
+ return exact or [f for f in found if serves(f["models"], model)]
622
+
623
+
624
+ def detect_servers(ports, timeout=1.0):
625
+ """[{url, port, usual, models}] for each local port with an OpenAI-compatible server answering.
626
+ usual: the app that normally uses that port (a hint, not a check)."""
627
+ found = []
628
+ for port in ports:
629
+ url = f"http://127.0.0.1:{port}"
630
+ ids = probe_models(url, timeout)
631
+ if ids is not None:
632
+ found.append({"url": url, "port": port, "usual": DETECT_PORTS.get(port), "models": ids})
633
+ return found
634
+
635
+
636
+ def serves(ids, name):
637
+ """Does a server listing these model ids serve this model (names match loosely: aliases, paths)?"""
638
+ want = name.lower()
639
+ return any(want == i.lower() or want in i.lower() or (i and i.lower() in want) for i in ids)
640
+
641
+
507
642
  def pack_min_context(pack):
508
643
  """Rough context a pack needs: its longest prompt (~4 chars/token) plus room to answer."""
509
644
  return max(len(t["input"]) for t in pack.tests) // 4 + 4096
@@ -614,6 +749,15 @@ class Engine:
614
749
  raise ConfigError(f"couldn't read {name}'s model list ({e})") from None
615
750
  return self._catalogues[name]
616
751
 
752
+ def has_results(self):
753
+ """Has any model run anything yet (smoke runs included)?"""
754
+ for base in (self.results_dir, os.path.join(self.results_dir, "smoke")):
755
+ for label in os.listdir(base) if os.path.isdir(base) else []:
756
+ d = os.path.join(base, label)
757
+ if os.path.isdir(d) and any(f.endswith(".json") for f in os.listdir(d)):
758
+ return True
759
+ return False
760
+
617
761
  def _add_remote_models(self):
618
762
  """Add the models named at run time this session, and ones that have results from an
619
763
  earlier session (their result files record the model), to cfg["models"]."""
@@ -868,7 +1012,7 @@ class Engine:
868
1012
  if job.merge:
869
1013
  data = self._result(job.out_path)
870
1014
  if data and data["pack"]["fingerprint"] == job.pack.fingerprint and settings_match(data, job.settings):
871
- kept = data["results"]
1015
+ kept = self._kept(job, data["results"])
872
1016
  if kept:
873
1017
  earlier = self._file_sittings(data["run"])
874
1018
  head, new = self._partial_lines(job.out_path + ".partial.jsonl")
@@ -877,6 +1021,14 @@ class Engine:
877
1021
  saved = self._saved_sittings(job)
878
1022
  return kept, new, earlier + saved + self._rebuild_sittings(new, saved + earlier)
879
1023
 
1024
+ @staticmethod
1025
+ def _kept(job, rows):
1026
+ """A finished file's rows that stay: all of them, less the picked tests' when redoing them."""
1027
+ if not job.redo:
1028
+ return rows
1029
+ redo = {t["id"] for t in job.tests}
1030
+ return [r for r in rows if r["test"] not in redo]
1031
+
880
1032
  def _fill_progress(self, job):
881
1033
  """Set a job's done/passed/failed and earlier sittings from what's stored."""
882
1034
  kept, new, job.earlier = self._stored(job)
@@ -927,11 +1079,13 @@ class Engine:
927
1079
  return not current and header_matches(head, pack.fingerprint, settings)
928
1080
 
929
1081
  # ---- planning
930
- def plan(self, labels, pack_names, repeat=None, tier="screen", force=False):
1082
+ def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None):
931
1083
  """Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
932
1084
  tests). repeat: times each question is asked; None = the tier default (1 for smoke and
933
- screen, the pack's certification repeats for certify). Earlier matching results are kept
934
- and only missing answers are run, unless force."""
1085
+ screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
1086
+ those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
1087
+ matching results are kept and only missing answers are run, unless force; force with
1088
+ picked tests reruns those and keeps the pack's other answers."""
935
1089
  if tier not in TIERS:
936
1090
  raise ValueError(f"tier must be one of {TIERS}")
937
1091
  jobs = []
@@ -940,8 +1094,10 @@ class Engine:
940
1094
  for name in pack_names:
941
1095
  pack = self.packs[name]
942
1096
  reps = repeat or self.default_repeat(pack, tier)
943
- job = Job(m, pack, reps, pack.select(tier), self.result_path(label, name, tier),
944
- effective_sampling(self.cfg, m), tier=tier, merge=not force, settings=self.settings(m))
1097
+ picked = (tests or {}).get(name)
1098
+ job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
1099
+ self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
1100
+ merge=not force or bool(picked), redo=force and bool(picked), settings=self.settings(m))
945
1101
  sv = self.serving(m)
946
1102
  status = self.result_status(label, name, tier)
947
1103
  job.discards = self._discards(job)
@@ -958,7 +1114,7 @@ class Engine:
958
1114
  elif missing:
959
1115
  job.status, job.note = "skipped", f"{', '.join(missing)} not installed in tuieval's Python"
960
1116
  elif status and status.startswith("outdated"):
961
- job.merge, job.note = False, status.split(": ", 1)[-1] + ", rerunning"
1117
+ job.merge, job.redo, job.note = False, False, status.split(": ", 1)[-1] + ", rerunning"
962
1118
  if not force: # a rerun under the new settings already under way resumes
963
1119
  self._fill_progress(job)
964
1120
  if job.done:
@@ -1387,6 +1543,9 @@ class Engine:
1387
1543
  raise ModelFailed(f"mmproj file not found: {expand(m['mmproj'])}")
1388
1544
  if cwd and not os.path.isdir(cwd):
1389
1545
  raise ModelFailed(f"server directory not found: {cwd}")
1546
+ missing = missing_program(cmd, cwd)
1547
+ if missing:
1548
+ raise ModelFailed(f"{missing} isn't installed: {install_hint(missing)}")
1390
1549
  if port_in_use(port):
1391
1550
  raise ModelFailed(f"port {port} is already in use; stop the server running there")
1392
1551
  os.makedirs(os.path.join(self.log_dir, "server"), exist_ok=True)
@@ -1411,7 +1570,7 @@ class Engine:
1411
1570
  key_env = server.get("api_key_env")
1412
1571
  if key_env and not os.environ.get(key_env):
1413
1572
  raise ModelFailed(f"{key_env} is not set; export it in the shell you start tuieval from")
1414
- where = f"already running at {base_url}"
1573
+ where = f"using the server at {base_url}"
1415
1574
  if server.get("pin_endpoint"):
1416
1575
  ep = self.endpoint(m)
1417
1576
  if not ep or not ep.get("tag"):
@@ -1421,6 +1580,14 @@ class Engine:
1421
1580
  self.emit("model_loading", label=m["label"], command=[f"({where})"], log="")
1422
1581
  t0 = time.time()
1423
1582
  deadline = time.time() + defaults["ready_timeout_s"]
1583
+ if srv is None and not server.get("health") and probe_models(base_url, 5, self.request_headers(m)) is None:
1584
+ # a server we don't start: if nothing answers now, waiting won't help
1585
+ time.sleep(3)
1586
+ if probe_models(base_url, 5, self.request_headers(m)) is None:
1587
+ raise ModelFailed(
1588
+ f"nothing is answering at {base_url}. Start that server, or {url_fix(self.cfg, m['server'])} "
1589
+ "(common local ports: LM Studio 1234, Ollama 11434, llama-server 8080, vLLM 8000); "
1590
+ "`tuieval doctor` checks them all")
1424
1591
  while True:
1425
1592
  self._check()
1426
1593
  if srv is not None and srv.poll() is not None:
@@ -1442,9 +1609,8 @@ class Engine:
1442
1609
  raise ModelFailed(f"server not ready after {defaults['ready_timeout_s']}s")
1443
1610
  time.sleep(0.5)
1444
1611
  info["load_s"] = time.time() - t0 if srv is not None else None
1445
- want = m["served_name"].lower()
1446
1612
  # A health-checked server we started ourselves serves exactly the model we gave it.
1447
- if not (server.get("health") and srv is not None) and not any(want == i.lower() or want in i.lower() or (i and i.lower() in want) for i in ids):
1613
+ if not (server.get("health") and srv is not None) and not serves(ids, m["served_name"]):
1448
1614
  raise ModelFailed(f"server reports {ids}, expected {m['served_name']!r}; "
1449
1615
  "not running to avoid mislabelled results")
1450
1616
  if log and server.get("log_facts"):
@@ -1656,7 +1822,7 @@ class Engine:
1656
1822
  data = self._result(job.out_path)
1657
1823
  if data and data["pack"]["fingerprint"] == job.pack.fingerprint and \
1658
1824
  settings_match(data, job.settings):
1659
- kept = data["results"]
1825
+ kept = self._kept(job, data["results"])
1660
1826
  if kept:
1661
1827
  job.earlier = self._file_sittings(data["run"])
1662
1828
  new = []
@@ -1789,18 +1955,19 @@ class Engine:
1789
1955
  job.note = f"{job.passed}/{job.done} passed"
1790
1956
  self.emit("job_done", job=job)
1791
1957
 
1792
- def _archive(self, job):
1793
- """Keep a result file that is about to be replaced (rerun or outdated) in history/."""
1958
+ def _archive(self, job, keep=False):
1959
+ """Keep a result file that is about to be replaced (rerun or outdated) in history/; keep:
1960
+ copy it (some of its answers stay in the new file)."""
1794
1961
  if job.tier == "smoke" or not os.path.isfile(job.out_path):
1795
1962
  return
1796
1963
  hist = os.path.join(os.path.dirname(job.out_path), "history")
1797
1964
  os.makedirs(hist, exist_ok=True)
1798
1965
  stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(job.out_path)))
1799
- os.replace(job.out_path, os.path.join(hist, f"{job.pack.name}-{stamp}.json"))
1966
+ (shutil.copy2 if keep else os.replace)(job.out_path, os.path.join(hist, f"{job.pack.name}-{stamp}.json"))
1800
1967
 
1801
1968
  def _write_result(self, job, records, run_info):
1802
- if not job.merge:
1803
- self._archive(job)
1969
+ if not job.merge or job.redo:
1970
+ self._archive(job, keep=job.redo)
1804
1971
  m = job.model
1805
1972
  data = {
1806
1973
  "format": RESULT_FORMAT,