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.
- quantdiff/__init__.py +53 -0
- quantdiff/__main__.py +5 -0
- quantdiff/_http.py +151 -0
- quantdiff/_text.py +13 -0
- quantdiff/_version.py +1 -0
- quantdiff/api.py +340 -0
- quantdiff/backends/__init__.py +28 -0
- quantdiff/backends/_common.py +342 -0
- quantdiff/backends/base.py +91 -0
- quantdiff/backends/llamacpp.py +428 -0
- quantdiff/backends/ollama.py +359 -0
- quantdiff/backends/openai_compat.py +338 -0
- quantdiff/cache.py +240 -0
- quantdiff/card.py +1664 -0
- quantdiff/cli.py +377 -0
- quantdiff/discover.py +488 -0
- quantdiff/errors.py +45 -0
- quantdiff/metrics/__init__.py +36 -0
- quantdiff/metrics/codeexec.py +428 -0
- quantdiff/metrics/jsonschema.py +610 -0
- quantdiff/metrics/logit.py +214 -0
- quantdiff/metrics/tasks.py +114 -0
- quantdiff/metrics/textsim.py +66 -0
- quantdiff/metrics/toolcheck.py +99 -0
- quantdiff/png.py +360 -0
- quantdiff/preflight.py +365 -0
- quantdiff/progress.py +283 -0
- quantdiff/py.typed +0 -0
- quantdiff/report.py +780 -0
- quantdiff/runner.py +492 -0
- quantdiff/spec.py +154 -0
- quantdiff/stats.py +226 -0
- quantdiff/suites/__init__.py +462 -0
- quantdiff/suites/data/chat.jsonl +22 -0
- quantdiff/suites/data/code.jsonl +32 -0
- quantdiff/suites/data/json.jsonl +34 -0
- quantdiff/suites/data/scoring.jsonl +41 -0
- quantdiff/suites/data/tools.jsonl +32 -0
- quantdiff/types.py +322 -0
- quantdiff/verdict.py +1513 -0
- quantdiff-0.1.0rc1.dist-info/METADATA +514 -0
- quantdiff-0.1.0rc1.dist-info/RECORD +45 -0
- quantdiff-0.1.0rc1.dist-info/WHEEL +4 -0
- quantdiff-0.1.0rc1.dist-info/entry_points.txt +2 -0
- 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
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)
|