quantdiff 0.1.0rc1__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.
Files changed (45) hide show
  1. quantdiff/__init__.py +53 -0
  2. quantdiff/__main__.py +5 -0
  3. quantdiff/_http.py +151 -0
  4. quantdiff/_text.py +13 -0
  5. quantdiff/_version.py +1 -0
  6. quantdiff/api.py +340 -0
  7. quantdiff/backends/__init__.py +28 -0
  8. quantdiff/backends/_common.py +342 -0
  9. quantdiff/backends/base.py +91 -0
  10. quantdiff/backends/llamacpp.py +428 -0
  11. quantdiff/backends/ollama.py +359 -0
  12. quantdiff/backends/openai_compat.py +338 -0
  13. quantdiff/cache.py +240 -0
  14. quantdiff/card.py +1664 -0
  15. quantdiff/cli.py +377 -0
  16. quantdiff/discover.py +488 -0
  17. quantdiff/errors.py +45 -0
  18. quantdiff/metrics/__init__.py +36 -0
  19. quantdiff/metrics/codeexec.py +428 -0
  20. quantdiff/metrics/jsonschema.py +610 -0
  21. quantdiff/metrics/logit.py +214 -0
  22. quantdiff/metrics/tasks.py +114 -0
  23. quantdiff/metrics/textsim.py +66 -0
  24. quantdiff/metrics/toolcheck.py +99 -0
  25. quantdiff/png.py +360 -0
  26. quantdiff/preflight.py +365 -0
  27. quantdiff/progress.py +283 -0
  28. quantdiff/py.typed +0 -0
  29. quantdiff/report.py +780 -0
  30. quantdiff/runner.py +492 -0
  31. quantdiff/spec.py +154 -0
  32. quantdiff/stats.py +226 -0
  33. quantdiff/suites/__init__.py +462 -0
  34. quantdiff/suites/data/chat.jsonl +22 -0
  35. quantdiff/suites/data/code.jsonl +32 -0
  36. quantdiff/suites/data/json.jsonl +34 -0
  37. quantdiff/suites/data/scoring.jsonl +41 -0
  38. quantdiff/suites/data/tools.jsonl +32 -0
  39. quantdiff/types.py +322 -0
  40. quantdiff/verdict.py +1513 -0
  41. quantdiff-0.1.0rc1.dist-info/METADATA +514 -0
  42. quantdiff-0.1.0rc1.dist-info/RECORD +45 -0
  43. quantdiff-0.1.0rc1.dist-info/WHEEL +4 -0
  44. quantdiff-0.1.0rc1.dist-info/entry_points.txt +2 -0
  45. quantdiff-0.1.0rc1.dist-info/licenses/LICENSE +202 -0
quantdiff/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """quantdiff: find out which download of a model gives the best answers on your prompts.
2
+
3
+ Typical use::
4
+
5
+ import quantdiff
6
+
7
+ report = quantdiff.compare(
8
+ "ollama:qwen2.5:7b-instruct-q8_0",
9
+ ["ollama:qwen2.5:7b-instruct-q4_K_M", "llamacpp:http://127.0.0.1:8080"],
10
+ )
11
+ print(quantdiff.verdict(report))
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from quantdiff._version import __version__
17
+ from quantdiff.api import build_plan, compare, write_run
18
+ from quantdiff.card import render_html, render_markdown, render_terminal
19
+ from quantdiff.errors import (
20
+ BackendError,
21
+ CapabilityError,
22
+ QuantdiffError,
23
+ ReportError,
24
+ SpecError,
25
+ SuiteError,
26
+ )
27
+ from quantdiff.report import load_report, rank_candidates, save_report, verdict
28
+ from quantdiff.spec import parse_spec
29
+ from quantdiff.types import CandidateResult, CandidateSpec, Report
30
+
31
+ __all__ = [
32
+ "BackendError",
33
+ "CandidateResult",
34
+ "CandidateSpec",
35
+ "CapabilityError",
36
+ "QuantdiffError",
37
+ "Report",
38
+ "ReportError",
39
+ "SpecError",
40
+ "SuiteError",
41
+ "__version__",
42
+ "build_plan",
43
+ "compare",
44
+ "load_report",
45
+ "parse_spec",
46
+ "rank_candidates",
47
+ "render_html",
48
+ "render_markdown",
49
+ "render_terminal",
50
+ "save_report",
51
+ "verdict",
52
+ "write_run",
53
+ ]
quantdiff/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from quantdiff.cli import main
4
+
5
+ raise SystemExit(main())
quantdiff/_http.py ADDED
@@ -0,0 +1,151 @@
1
+ """Minimal hardened JSON-over-HTTP client built on urllib.
2
+
3
+ Every network call in quantdiff goes through this module so the safety rules live in one
4
+ place: http and https only, no redirects, bounded response size, explicit timeouts, and
5
+ error messages that never echo request headers (which may carry an API key).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from collections.abc import Mapping
16
+ from typing import Final
17
+
18
+ from quantdiff._text import printable
19
+ from quantdiff.errors import BackendError, RequestError
20
+ from quantdiff.types import JSONValue
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ DEFAULT_TIMEOUT_SECONDS: Final = 600.0
25
+ DEFAULT_MAX_BYTES: Final = 64 * 1024 * 1024
26
+ _ALLOWED_SCHEMES: Final = frozenset({"http", "https"})
27
+ _ERROR_BODY_PREVIEW: Final = 300
28
+
29
+
30
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
31
+ """Refuse redirects so a server cannot bounce requests, and their headers, elsewhere."""
32
+
33
+ def redirect_request(self, *args: object, **kwargs: object) -> None:
34
+ # urllib calls this with six fixed arguments; none matter, every redirect is refused.
35
+ return None
36
+
37
+
38
+ # An empty ProxyHandler ignores HTTP(S)_PROXY: requests, and any API key they carry, go only
39
+ # to the servers the user named.
40
+ _OPENER: Final = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
41
+
42
+
43
+ def validate_base_url(url: str) -> str:
44
+ """Return `url` without a trailing slash, or raise BackendError if it is unsafe to use."""
45
+ parsed = _check_url(url)
46
+ if parsed.query or parsed.fragment:
47
+ raise BackendError("base URLs must not contain a query string or fragment")
48
+ return url.rstrip("/")
49
+
50
+
51
+ def _check_url(url: str) -> urllib.parse.SplitResult:
52
+ parsed = urllib.parse.urlsplit(url)
53
+ if parsed.scheme not in _ALLOWED_SCHEMES:
54
+ raise BackendError(f"unsupported URL scheme {parsed.scheme!r}; use http or https")
55
+ if not parsed.hostname:
56
+ raise BackendError(f"URL has no host: {url!r}")
57
+ if parsed.username or parsed.password:
58
+ raise BackendError("credentials in URLs are not allowed; use an API key variable")
59
+ return parsed
60
+
61
+
62
+ def request_json(
63
+ method: str,
64
+ url: str,
65
+ payload: Mapping[str, JSONValue] | None = None,
66
+ *,
67
+ headers: Mapping[str, str] | None = None,
68
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
69
+ max_bytes: int = DEFAULT_MAX_BYTES,
70
+ ) -> JSONValue:
71
+ """Send a JSON request and return the decoded JSON response.
72
+
73
+ Raises BackendError for transport failures, non-2xx statuses, oversized bodies and
74
+ invalid JSON.
75
+ """
76
+ _check_url(url)
77
+ body = None if payload is None else json.dumps(payload).encode("utf-8")
78
+ request = urllib.request.Request(url, data=body, method=method) # noqa: S310 - scheme checked above
79
+ request.add_header("Accept", "application/json")
80
+ if body is not None:
81
+ request.add_header("Content-Type", "application/json")
82
+ for name, value in (headers or {}).items():
83
+ request.add_header(name, value)
84
+
85
+ logger.debug("%s %s", method, url)
86
+ try:
87
+ with _OPENER.open(request, timeout=timeout) as response:
88
+ raw = response.read(max_bytes + 1)
89
+ except urllib.error.HTTPError as exc:
90
+ preview = _read_preview(exc)
91
+ raise RequestError(
92
+ f"{method} {url} returned HTTP {exc.code}: {preview}",
93
+ url=url,
94
+ status=exc.code,
95
+ detail=preview,
96
+ ) from None
97
+ except urllib.error.URLError as exc:
98
+ reason = printable(str(exc.reason))
99
+ raise RequestError(
100
+ f"{method} {url} failed: {reason}", url=url, status=None, detail=reason
101
+ ) from None
102
+ except TimeoutError:
103
+ reason = f"timed out after {timeout:g}s"
104
+ raise RequestError(
105
+ f"{method} {url} {reason}", url=url, status=None, detail=reason
106
+ ) from None
107
+ except OSError as exc:
108
+ reason = printable(str(exc))
109
+ raise RequestError(
110
+ f"{method} {url} failed: {reason}", url=url, status=None, detail=reason
111
+ ) from None
112
+
113
+ if len(raw) > max_bytes:
114
+ raise BackendError(f"{method} {url} returned more than {max_bytes} bytes")
115
+ try:
116
+ return json.loads(raw, parse_constant=_reject_constant)
117
+ except (UnicodeDecodeError, ValueError, RecursionError):
118
+ raise BackendError(f"{method} {url} returned a body that is not valid JSON") from None
119
+
120
+
121
+ def get_json(
122
+ url: str,
123
+ *,
124
+ headers: Mapping[str, str] | None = None,
125
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
126
+ max_bytes: int = DEFAULT_MAX_BYTES,
127
+ ) -> JSONValue:
128
+ return request_json("GET", url, headers=headers, timeout=timeout, max_bytes=max_bytes)
129
+
130
+
131
+ def post_json(
132
+ url: str,
133
+ payload: Mapping[str, JSONValue],
134
+ *,
135
+ headers: Mapping[str, str] | None = None,
136
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
137
+ max_bytes: int = DEFAULT_MAX_BYTES,
138
+ ) -> JSONValue:
139
+ return request_json("POST", url, payload, headers=headers, timeout=timeout, max_bytes=max_bytes)
140
+
141
+
142
+ def _read_preview(exc: urllib.error.HTTPError) -> str:
143
+ try:
144
+ text = exc.read(_ERROR_BODY_PREVIEW).decode("utf-8", errors="replace")
145
+ except OSError:
146
+ return "<unreadable body>"
147
+ return " ".join(printable(text).split()) or "<empty body>"
148
+
149
+
150
+ def _reject_constant(name: str) -> JSONValue:
151
+ raise ValueError(f"non-standard JSON constant {name}")
quantdiff/_text.py ADDED
@@ -0,0 +1,13 @@
1
+ """Helpers for showing untrusted text (server responses, model ids) in a terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def printable(text: str) -> str:
7
+ """Replace control characters so untrusted text cannot emit terminal escape sequences."""
8
+ return "".join(char if char.isprintable() else " " for char in text)
9
+
10
+
11
+ def printable_lines(text: str) -> str:
12
+ """Sanitize each line of a possibly multi-line message, keeping the line breaks."""
13
+ return "\n".join(printable(line) for line in text.splitlines())
quantdiff/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0rc1"
quantdiff/api.py ADDED
@@ -0,0 +1,340 @@
1
+ """The Python entry points: compare() for a full run and write_run() to persist one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from collections import Counter
9
+ from collections.abc import Iterable, Sequence
10
+ from dataclasses import dataclass, replace
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Final
14
+
15
+ from quantdiff.cache import ReferenceCache
16
+ from quantdiff.card import render_html, render_markdown
17
+ from quantdiff.errors import RenderError, SpecError, SuiteError
18
+ from quantdiff.png import render_png
19
+ from quantdiff.preflight import MIN_CONTEXT_PROBE_TOKENS
20
+ from quantdiff.report import save_report
21
+ from quantdiff.runner import ProgressFn, RunPlan, execute
22
+ from quantdiff.spec import parse_spec
23
+ from quantdiff.suites import (
24
+ BUILTIN_SUITES,
25
+ load_builtin,
26
+ load_cases_file,
27
+ load_scoring_prompts,
28
+ scoring_prompts_from_cases,
29
+ )
30
+ from quantdiff.types import CandidateSpec, Report, RunSettings, ScoringPrompt, TaskCase
31
+ from quantdiff.verdict import Verdict
32
+
33
+ DEFAULT_SUITES = ("json", "tools", "chat")
34
+ PathLike = str | os.PathLike[str]
35
+
36
+ _TITLE_SEPARATORS: Final = ":-_/."
37
+ _MIN_TITLE_PREFIX: Final = 8
38
+ _CHARS_PER_TOKEN: Final = 4
39
+ _SIZE_PATTERN: Final = re.compile(r"(\d+(?:\.\d+)?)\s*([a-z]*)")
40
+ _SIZE_UNITS: Final[dict[str, int]] = {
41
+ "": 1,
42
+ "b": 1,
43
+ "k": 10**3,
44
+ "kb": 10**3,
45
+ "m": 10**6,
46
+ "mb": 10**6,
47
+ "g": 10**9,
48
+ "gb": 10**9,
49
+ "t": 10**12,
50
+ "tb": 10**12,
51
+ }
52
+ """Decimal units, as download pages and file managers quote sizes."""
53
+
54
+
55
+ def build_plan(
56
+ ref: str | CandidateSpec,
57
+ candidates: Sequence[str | CandidateSpec],
58
+ *,
59
+ suites: Sequence[str] | None = None,
60
+ prompts: PathLike | None = None,
61
+ scoring_prompts: PathLike | None = None,
62
+ top_k: int = 10,
63
+ score_tokens: int = 32,
64
+ max_cases: int | None = None,
65
+ allow_code_exec: bool = False,
66
+ seed: int = 0,
67
+ hf_repo: str | None = None,
68
+ offline: bool = False,
69
+ preflight: bool = True,
70
+ context_probe_tokens: int = 6000,
71
+ title: str | None = None,
72
+ max_size: int | str | None = None,
73
+ ) -> RunPlan:
74
+ """Resolve specs, suites and settings into a validated RunPlan.
75
+
76
+ With no `suites` and no `prompts`, the default suites run (plus code when
77
+ `allow_code_exec` is set). With `prompts` and no `suites`, only the user's prompts run.
78
+ With `prompts` and no `scoring_prompts`, the logit tier scores the user's own prompt
79
+ texts. `max_cases` caps each suite and the number of scoring prompts. Without a
80
+ `title`, one is derived from the labels. `max_size` is the size budget for the pick,
81
+ in bytes or as text such as "6GB", "6.5G" or "800MB" (decimal units).
82
+ """
83
+ if not candidates:
84
+ raise SpecError("at least one candidate is required")
85
+ if not 1 <= top_k <= 20:
86
+ raise SpecError("top_k must be between 1 and 20")
87
+ if score_tokens < 0:
88
+ raise SpecError("score_tokens must be zero or positive")
89
+ if max_cases is not None and max_cases < 1:
90
+ raise SpecError("max_cases must be at least 1")
91
+ if preflight and context_probe_tokens != 0 and context_probe_tokens < MIN_CONTEXT_PROBE_TOKENS:
92
+ raise SpecError(
93
+ f"context_probe_tokens must be 0 (off) or at least {MIN_CONTEXT_PROBE_TOKENS}"
94
+ )
95
+
96
+ budget = parse_size(max_size) if isinstance(max_size, str) else max_size
97
+ if budget is not None and budget < 1:
98
+ raise SpecError("max_size must be a positive number of bytes")
99
+ specs = _unique_labels([_as_spec(ref), *(_as_spec(c) for c in candidates)])
100
+ suite_names = _resolve_suites(suites, has_prompts=prompts is not None, code=allow_code_exec)
101
+ user_cases = () if prompts is None else load_cases_file(prompts)[:max_cases]
102
+ cases = _combine_cases(suite_names, user_cases, max_cases)
103
+ scoring = _load_scoring(scoring_prompts, user_cases, max_cases) if score_tokens > 0 else ()
104
+
105
+ settings = RunSettings(
106
+ suites=tuple(suite_names),
107
+ top_k=top_k,
108
+ score_tokens=score_tokens,
109
+ allow_code_exec=allow_code_exec,
110
+ seed=seed,
111
+ prompts_file=None if prompts is None else Path(prompts).name,
112
+ max_size_bytes=budget,
113
+ longest_prompt_tokens=_longest_prompt_tokens(cases, scoring),
114
+ )
115
+ return RunPlan(
116
+ reference=specs[0],
117
+ candidates=tuple(specs[1:]),
118
+ cases=cases,
119
+ scoring=scoring,
120
+ settings=settings,
121
+ title=title or _default_title([spec.label for spec in specs]),
122
+ hf_repo=hf_repo,
123
+ offline=offline,
124
+ preflight=preflight,
125
+ context_probe_tokens=context_probe_tokens,
126
+ )
127
+
128
+
129
+ def compare(
130
+ ref: str | CandidateSpec,
131
+ candidates: Sequence[str | CandidateSpec],
132
+ *,
133
+ suites: Sequence[str] | None = None,
134
+ prompts: PathLike | None = None,
135
+ scoring_prompts: PathLike | None = None,
136
+ top_k: int = 10,
137
+ score_tokens: int = 32,
138
+ max_cases: int | None = None,
139
+ allow_code_exec: bool = False,
140
+ seed: int = 0,
141
+ hf_repo: str | None = None,
142
+ offline: bool = False,
143
+ preflight: bool = True,
144
+ context_probe_tokens: int = 6000,
145
+ title: str | None = None,
146
+ max_size: int | str | None = None,
147
+ use_cache: bool = True,
148
+ progress: ProgressFn | None = None,
149
+ ) -> Report:
150
+ """Compare candidate model servers against a reference and return the Report.
151
+
152
+ Options match build_plan(). `use_cache` reuses reference outputs from earlier runs with
153
+ the same model, suites and settings.
154
+ """
155
+ plan = build_plan(
156
+ ref,
157
+ candidates,
158
+ suites=suites,
159
+ prompts=prompts,
160
+ scoring_prompts=scoring_prompts,
161
+ top_k=top_k,
162
+ score_tokens=score_tokens,
163
+ max_cases=max_cases,
164
+ allow_code_exec=allow_code_exec,
165
+ seed=seed,
166
+ hf_repo=hf_repo,
167
+ offline=offline,
168
+ preflight=preflight,
169
+ context_probe_tokens=context_probe_tokens,
170
+ title=title,
171
+ max_size=max_size,
172
+ )
173
+ return execute(plan, cache=ReferenceCache() if use_cache else None, progress=progress)
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class RunFiles:
178
+ """Where write_run() put each artifact. `png` is None when no browser was available."""
179
+
180
+ directory: Path
181
+ report: Path
182
+ html: Path
183
+ markdown: Path
184
+ png: Path | None
185
+ png_error: str | None = None
186
+
187
+
188
+ def write_run(
189
+ report: Report,
190
+ out_dir: PathLike = "runs",
191
+ *,
192
+ png: bool = True,
193
+ verdict: Verdict | None = None,
194
+ ) -> RunFiles:
195
+ """Write report.json, card.html, card.md and (when a browser is available) card.png
196
+ into a new timestamped directory under `out_dir`. The cards show `verdict`, or
197
+ judge(report) when it is None.
198
+
199
+ PNG rendering is best effort: a missing browser is reported in `RunFiles.png_error`
200
+ rather than raised, because the other artifacts are already complete.
201
+ """
202
+ run_dir = _new_run_dir(Path(out_dir))
203
+ html = render_html(report, verdict=verdict)
204
+ files = RunFiles(
205
+ directory=run_dir,
206
+ report=run_dir / "report.json",
207
+ html=run_dir / "card.html",
208
+ markdown=run_dir / "card.md",
209
+ png=None,
210
+ )
211
+ save_report(report, files.report)
212
+ files.html.write_text(html, encoding="utf-8")
213
+ files.markdown.write_text(render_markdown(report, verdict=verdict), encoding="utf-8")
214
+ if not png:
215
+ return files
216
+ try:
217
+ return replace(files, png=render_png(html, run_dir / "card.png"))
218
+ except RenderError as exc:
219
+ return replace(files, png_error=str(exc))
220
+
221
+
222
+ def parse_size(text: str) -> int:
223
+ """Bytes in a size such as "6GB", "6.5G", "800MB", "800M" or "6000000000".
224
+
225
+ Units are decimal (1 GB is 10**9 bytes) and case does not matter. Raises SpecError
226
+ for anything else, including binary units such as GiB, so a budget is never misread.
227
+ """
228
+ match = _SIZE_PATTERN.fullmatch(text.strip().lower())
229
+ unit = None if match is None else _SIZE_UNITS.get(match.group(2))
230
+ if match is None or unit is None:
231
+ raise SpecError(
232
+ f"max size {text!r} is not a size; use a value such as 6GB, 6.5G, 800MB or a "
233
+ "byte count (decimal units)"
234
+ )
235
+ number = match.group(1)
236
+ if unit == 1 and "." in number:
237
+ raise SpecError(f"max size {text!r} is a fraction of a byte; add a unit such as GB")
238
+ size = round(float(number) * unit)
239
+ if size < 1:
240
+ raise SpecError(f"max size {text!r} must be more than zero")
241
+ return size
242
+
243
+
244
+ def _new_run_dir(root: Path) -> Path:
245
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
246
+ run_dir = root / stamp
247
+ suffix = 1
248
+ while run_dir.exists():
249
+ suffix += 1
250
+ run_dir = root / f"{stamp}-{suffix}"
251
+ run_dir.mkdir(parents=True)
252
+ return run_dir
253
+
254
+
255
+ def _as_spec(value: str | CandidateSpec) -> CandidateSpec:
256
+ return value if isinstance(value, CandidateSpec) else parse_spec(value)
257
+
258
+
259
+ def _unique_labels(specs: Iterable[CandidateSpec]) -> list[CandidateSpec]:
260
+ seen: dict[str, int] = {}
261
+ unique = []
262
+ for spec in specs:
263
+ count = seen.get(spec.label, 0) + 1
264
+ seen[spec.label] = count
265
+ unique.append(spec if count == 1 else replace(spec, label=f"{spec.label} #{count}"))
266
+ return unique
267
+
268
+
269
+ def _default_title(labels: Sequence[str]) -> str:
270
+ """Name a run after its models, reference first.
271
+
272
+ Labels that share a prefix ending in one of `:-_/.` (at least 8 characters) become
273
+ "<prefix>: q8_0 vs q4_K_M"; anything else becomes "<reference> vs <n> candidates".
274
+ """
275
+ reference, *others = labels
276
+ prefix = _shared_prefix(labels)
277
+ if prefix:
278
+ return f"{prefix[:-1]}: " + " vs ".join(label[len(prefix) :] for label in labels)
279
+ if len(others) == 1:
280
+ return f"{reference} vs {others[0]}"
281
+ return f"{reference} vs {len(others)} candidates"
282
+
283
+
284
+ def _shared_prefix(labels: Sequence[str]) -> str | None:
285
+ common = os.path.commonprefix(list(labels))
286
+ end = max(common.rfind(separator) for separator in _TITLE_SEPARATORS) + 1
287
+ prefix = common[:end]
288
+ if len(prefix) < _MIN_TITLE_PREFIX or any(len(label) == end for label in labels):
289
+ return None
290
+ return prefix
291
+
292
+
293
+ def _resolve_suites(suites: Sequence[str] | None, *, has_prompts: bool, code: bool) -> list[str]:
294
+ if suites is None:
295
+ if has_prompts:
296
+ return []
297
+ return [*DEFAULT_SUITES, "code"] if code else list(DEFAULT_SUITES)
298
+ names = list(dict.fromkeys(suites))
299
+ unknown = [name for name in names if name not in BUILTIN_SUITES]
300
+ if unknown:
301
+ known = ", ".join(BUILTIN_SUITES)
302
+ raise SuiteError(f"unknown suite(s): {', '.join(unknown)}; choose from {known}")
303
+ if "code" in names and not code:
304
+ raise SpecError(
305
+ "the code suite runs model-written code; add --allow-code-exec to enable it"
306
+ )
307
+ return names
308
+
309
+
310
+ def _combine_cases(
311
+ suite_names: Sequence[str], user_cases: Sequence[TaskCase], max_cases: int | None
312
+ ) -> tuple[TaskCase, ...]:
313
+ cases: list[TaskCase] = []
314
+ for name in suite_names:
315
+ cases.extend(load_builtin(name)[:max_cases])
316
+ cases.extend(user_cases)
317
+ counts = Counter(case.id for case in cases)
318
+ duplicates = sorted(case_id for case_id, count in counts.items() if count > 1)
319
+ if duplicates:
320
+ raise SuiteError(f"duplicate case ids across suites: {', '.join(duplicates)}")
321
+ if not cases:
322
+ raise SuiteError("nothing to run: no suites selected and no prompts file given")
323
+ return tuple(cases)
324
+
325
+
326
+ def _load_scoring(
327
+ path: PathLike | None, user_cases: Sequence[TaskCase], max_cases: int | None
328
+ ) -> tuple[ScoringPrompt, ...]:
329
+ if path is None and user_cases:
330
+ return scoring_prompts_from_cases(user_cases)[:max_cases]
331
+ return load_scoring_prompts(path)[:max_cases]
332
+
333
+
334
+ def _longest_prompt_tokens(
335
+ cases: Sequence[TaskCase], scoring: Sequence[ScoringPrompt]
336
+ ) -> int | None:
337
+ """Rough token count of the longest prompt, at about four characters per token."""
338
+ lengths = [sum(len(message.content) for message in case.messages) for case in cases]
339
+ lengths += [len(prompt.text) for prompt in scoring]
340
+ return math.ceil(max(lengths) / _CHARS_PER_TOKEN) if lengths else None
@@ -0,0 +1,28 @@
1
+ """Model server adapters and the factory that picks one for a CandidateSpec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from quantdiff._http import DEFAULT_TIMEOUT_SECONDS
6
+ from quantdiff.backends.base import MAX_TOP_K, Backend
7
+ from quantdiff.backends.llamacpp import LlamaCppBackend
8
+ from quantdiff.backends.ollama import OllamaBackend
9
+ from quantdiff.backends.openai_compat import OpenAICompatBackend
10
+ from quantdiff.types import CandidateSpec
11
+
12
+ __all__ = [
13
+ "MAX_TOP_K",
14
+ "Backend",
15
+ "LlamaCppBackend",
16
+ "OllamaBackend",
17
+ "OpenAICompatBackend",
18
+ "open_backend",
19
+ ]
20
+
21
+
22
+ def open_backend(spec: CandidateSpec, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Backend:
23
+ """Return the adapter for `spec.kind`. No request is sent until the first call."""
24
+ if spec.kind == "ollama":
25
+ return OllamaBackend(spec, timeout=timeout)
26
+ if spec.kind == "llamacpp":
27
+ return LlamaCppBackend(spec, timeout=timeout)
28
+ return OpenAICompatBackend(spec, timeout=timeout)