quantgap 0.1.0__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.
- quantgap/__init__.py +3 -0
- quantgap/backends/__init__.py +60 -0
- quantgap/backends/base.py +118 -0
- quantgap/backends/hf.py +77 -0
- quantgap/backends/llamacpp.py +99 -0
- quantgap/backends/mlx.py +86 -0
- quantgap/backends/openai_compat.py +164 -0
- quantgap/cli.py +521 -0
- quantgap/config.py +60 -0
- quantgap/hub/__init__.py +1 -0
- quantgap/hub/packs.py +142 -0
- quantgap/hub/resolve.py +247 -0
- quantgap/hub/submit.py +87 -0
- quantgap/models.py +202 -0
- quantgap/pack/__init__.py +4 -0
- quantgap/pack/reader.py +142 -0
- quantgap/pack/runner.py +301 -0
- quantgap/pack/writer.py +162 -0
- quantgap/probes/__init__.py +11 -0
- quantgap/probes/build.py +484 -0
- quantgap/probes/data/SOURCES.md +15 -0
- quantgap/probes/data/probes_v1.jsonl +160 -0
- quantgap/probes/registry.py +34 -0
- quantgap/probes/render.py +158 -0
- quantgap/report/__init__.py +0 -0
- quantgap/report/chart.py +92 -0
- quantgap/report/jsonout.py +25 -0
- quantgap/report/table.py +82 -0
- quantgap/score/__init__.py +4 -0
- quantgap/score/compare.py +33 -0
- quantgap/score/damage.py +103 -0
- quantgap/score/kld.py +73 -0
- quantgap/score/recommend.py +78 -0
- quantgap/score/run.py +260 -0
- quantgap/score/stats.py +33 -0
- quantgap/tokcheck.py +71 -0
- quantgap/util.py +95 -0
- quantgap-0.1.0.dist-info/METADATA +311 -0
- quantgap-0.1.0.dist-info/RECORD +42 -0
- quantgap-0.1.0.dist-info/WHEEL +4 -0
- quantgap-0.1.0.dist-info/entry_points.txt +2 -0
- quantgap-0.1.0.dist-info/licenses/LICENSE +202 -0
quantgap/hub/packs.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Find, download and publish packs (Hugging Face datasets tagged `quantgap-pack`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from quantgap.config import Settings
|
|
9
|
+
from quantgap.models import Manifest
|
|
10
|
+
from quantgap.pack.reader import Pack
|
|
11
|
+
|
|
12
|
+
PACK_TAG = "quantgap-pack"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PackNotFound(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _norm(s: str | None) -> str:
|
|
20
|
+
return (s or "").strip().lower().removeprefix("https://huggingface.co/")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def list_packs(settings: Settings, model_query: str | None = None) -> list[tuple[str, Manifest]]:
|
|
24
|
+
"""(dataset id, manifest) for every known pack, optionally filtered by model name."""
|
|
25
|
+
from huggingface_hub import HfApi, hf_hub_download
|
|
26
|
+
|
|
27
|
+
api = HfApi()
|
|
28
|
+
out: list[tuple[str, Manifest]] = []
|
|
29
|
+
for ds in api.list_datasets(filter=PACK_TAG, author=settings.hub.packs_org):
|
|
30
|
+
if model_query and model_query.lower() not in ds.id.lower():
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
path = hf_hub_download(ds.id, "manifest.json", repo_type="dataset")
|
|
34
|
+
out.append((ds.id, Manifest.model_validate(json.loads(Path(path).read_text("utf-8")))))
|
|
35
|
+
except Exception:
|
|
36
|
+
continue
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_pack(ref: str, verify: bool = True) -> Pack:
|
|
41
|
+
"""`ref` is a local folder or a Hub dataset id like `quantgap-packs/Qwen3-8B`."""
|
|
42
|
+
p = Path(ref).expanduser()
|
|
43
|
+
if p.is_dir():
|
|
44
|
+
return Pack.load(p, verify=verify)
|
|
45
|
+
from huggingface_hub import snapshot_download
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
local = snapshot_download(ref, repo_type="dataset")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise PackNotFound(
|
|
51
|
+
f"Could not find a pack at {ref!r} (not a folder, not a Hub dataset): {e}"
|
|
52
|
+
) from e
|
|
53
|
+
return Pack.load(local, verify=verify)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def auto_find_pack(base_model: str | None, settings: Settings) -> Pack:
|
|
57
|
+
"""Find the pack that matches a quant's base model, or explain what to do (spec E1)."""
|
|
58
|
+
if not base_model:
|
|
59
|
+
raise PackNotFound(
|
|
60
|
+
"This quant does not say which model it was made from, so no pack can be chosen "
|
|
61
|
+
"automatically. Pass --pack PACK_REF (a pack folder or a Hub dataset id). "
|
|
62
|
+
"List packs with: quantgap packs list"
|
|
63
|
+
)
|
|
64
|
+
packs = list_packs(settings)
|
|
65
|
+
# Exact model name first. Many GGUFs name the parent of an instruct model as base_model
|
|
66
|
+
# (Qwen2.5-0.5B for Qwen2.5-0.5B-Instruct), so fall back to the same model family.
|
|
67
|
+
matches = [(ds, m) for ds, m in packs if _norm(m.model.repo) == _norm(base_model)]
|
|
68
|
+
if not matches:
|
|
69
|
+
matches = [(ds, m) for ds, m in packs if _same_family(base_model, m.model.repo)]
|
|
70
|
+
if not matches:
|
|
71
|
+
raise PackNotFound(
|
|
72
|
+
f"No pack found for {base_model}. Make one with: quantgap pack {base_model} "
|
|
73
|
+
"(see docs/packs.md), or pass --pack."
|
|
74
|
+
)
|
|
75
|
+
if len(matches) > 1:
|
|
76
|
+
options = "\n".join(f" {ds} ({m.short_name}, probes {m.probes.set})" for ds, m in matches)
|
|
77
|
+
raise PackNotFound(
|
|
78
|
+
f"More than one pack matches {base_model}. Pick one with --pack:\n{options}"
|
|
79
|
+
)
|
|
80
|
+
return load_pack(matches[0][0])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _stem(repo: str) -> str:
|
|
84
|
+
return _norm(repo).split("/")[-1]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _same_family(a: str, b: str) -> bool:
|
|
88
|
+
x, y = _stem(a), _stem(b)
|
|
89
|
+
return x == y or x.startswith(y) or y.startswith(x)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fine_tune_warning(base_model: str | None, pack: Pack) -> str | None:
|
|
93
|
+
"""E2: the quant says it comes from a different model than the pack's.
|
|
94
|
+
|
|
95
|
+
GGUF files often name the *parent* of an instruct model (Qwen2.5-0.5B for
|
|
96
|
+
Qwen2.5-0.5B-Instruct), so names where one starts with the other count as the same family.
|
|
97
|
+
"""
|
|
98
|
+
if not base_model:
|
|
99
|
+
return None
|
|
100
|
+
if _same_family(base_model, pack.manifest.model.repo):
|
|
101
|
+
return None
|
|
102
|
+
return (
|
|
103
|
+
f"This looks like a fine-tune ({base_model}, not {pack.manifest.model.repo}). "
|
|
104
|
+
"Its drift includes the fine-tuning, not only quantization."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dataset_card(m: Manifest) -> str:
|
|
109
|
+
lic = m.model.license or "unknown (check the model page before sharing)"
|
|
110
|
+
return f"""---
|
|
111
|
+
license: cc-by-4.0
|
|
112
|
+
tags:
|
|
113
|
+
- {PACK_TAG}
|
|
114
|
+
- quantization
|
|
115
|
+
pretty_name: quantgap pack for {m.model.repo}
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
# quantgap pack: {m.model.repo}
|
|
119
|
+
|
|
120
|
+
Pack id: `{m.pack_id}`. Made with quantgap {m.runner.quantgap}.
|
|
121
|
+
|
|
122
|
+
This pack holds the reference model's top-{m.k} next-token log probabilities on the quantgap
|
|
123
|
+
probe set `{m.probes.set}` ({m.probes.n_positions:,} positions). Use it with
|
|
124
|
+
`quantgap score QUANT --pack <this dataset>` to measure how far a quantized copy of the model
|
|
125
|
+
drifts from the original, without running the original.
|
|
126
|
+
|
|
127
|
+
- Reference model: `{m.model.repo}` @ `{m.model.revision}` ({m.model.dtype})
|
|
128
|
+
- Reference model license: **{lic}**
|
|
129
|
+
- The pack contains model outputs (log probabilities) on openly licensed texts. It does not
|
|
130
|
+
contain the model weights.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def push_pack(pack_dir: str | Path, repo_id: str, token: str | None = None) -> str:
|
|
135
|
+
from huggingface_hub import HfApi
|
|
136
|
+
|
|
137
|
+
pack = Pack.load(pack_dir)
|
|
138
|
+
api = HfApi(token=token)
|
|
139
|
+
api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
|
|
140
|
+
(Path(pack_dir) / "README.md").write_text(dataset_card(pack.manifest), encoding="utf-8")
|
|
141
|
+
api.upload_folder(folder_path=str(pack_dir), repo_id=repo_id, repo_type="dataset")
|
|
142
|
+
return f"https://huggingface.co/datasets/{repo_id}"
|
quantgap/hub/resolve.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Turn what the user typed into a quant on disk: local paths, hf:// URLs, repo globs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
import struct
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from quantgap.backends.base import ResolvedSource
|
|
13
|
+
from quantgap.util import free_disk_bytes, human_size, sha256_file
|
|
14
|
+
|
|
15
|
+
_LABEL_RE = re.compile(
|
|
16
|
+
r"((?:UD-)?(?:IQ\d(?:_[A-Z0-9]+)*|Q\d(?:_[A-Z0-9]+)*|MXFP4(?:_MOE)?|BF16|F16|F32|NVFP4))",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResolveError(ValueError):
|
|
22
|
+
"""The quant reference could not be understood or found. The message says what to try."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class HFRef:
|
|
27
|
+
repo: str
|
|
28
|
+
path: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_hf_url(url: str) -> HFRef:
|
|
32
|
+
"""`hf://org/repo` or `hf://org/repo/sub/folder/file.gguf`."""
|
|
33
|
+
if not url.startswith("hf://"):
|
|
34
|
+
raise ResolveError(f"{url!r} is not an hf:// URL. Example: hf://org/repo/file.gguf")
|
|
35
|
+
parts = [p for p in url[len("hf://") :].split("/") if p]
|
|
36
|
+
if len(parts) < 2:
|
|
37
|
+
raise ResolveError(
|
|
38
|
+
f"{url!r} needs at least org/repo. Example: hf://unsloth/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf"
|
|
39
|
+
)
|
|
40
|
+
return HFRef(repo="/".join(parts[:2]), path="/".join(parts[2:]) or None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def label_from_filename(name: str) -> str:
|
|
44
|
+
stem = Path(name).stem
|
|
45
|
+
m = _LABEL_RE.search(stem)
|
|
46
|
+
return m.group(1).upper() if m else stem
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def expand_glob(repo: str, pattern: str, files: list[str] | None = None) -> list[str]:
|
|
50
|
+
"""Repo file paths matching a glob. `files` can be given (tests); otherwise asks the Hub."""
|
|
51
|
+
if files is None:
|
|
52
|
+
from huggingface_hub import HfApi
|
|
53
|
+
|
|
54
|
+
files = HfApi().list_repo_files(repo)
|
|
55
|
+
hits = sorted(
|
|
56
|
+
f for f in files if fnmatch.fnmatch(f, pattern) or fnmatch.fnmatch(Path(f).name, pattern)
|
|
57
|
+
)
|
|
58
|
+
# Split GGUFs ("-00001-of-00003.gguf") are not supported yet: keep only single files or first parts.
|
|
59
|
+
out = []
|
|
60
|
+
for f in hits:
|
|
61
|
+
m = re.search(r"-(\d{5})-of-(\d{5})\.gguf$", f)
|
|
62
|
+
if m and m.group(1) != "00001":
|
|
63
|
+
continue
|
|
64
|
+
out.append(f)
|
|
65
|
+
if not out:
|
|
66
|
+
raise ResolveError(f"No files in {repo} match {pattern!r}.")
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------ GGUF metadata (no deps)
|
|
71
|
+
|
|
72
|
+
_GGUF_TYPES = {
|
|
73
|
+
0: "B",
|
|
74
|
+
1: "b",
|
|
75
|
+
2: "H",
|
|
76
|
+
3: "h",
|
|
77
|
+
4: "I",
|
|
78
|
+
5: "i",
|
|
79
|
+
6: "f",
|
|
80
|
+
7: "?",
|
|
81
|
+
10: "Q",
|
|
82
|
+
11: "q",
|
|
83
|
+
12: "d",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_gguf_metadata(path: str | Path, wanted: set[str] | None = None) -> dict[str, Any]:
|
|
88
|
+
"""Read key/value metadata from a GGUF header. Arrays are skipped. Never loads tensors."""
|
|
89
|
+
out: dict[str, Any] = {}
|
|
90
|
+
with open(path, "rb") as f:
|
|
91
|
+
if f.read(4) != b"GGUF":
|
|
92
|
+
raise ResolveError(f"{path} is not a GGUF file.")
|
|
93
|
+
(_version,) = struct.unpack("<I", f.read(4))
|
|
94
|
+
_n_tensors, n_kv = struct.unpack("<QQ", f.read(16))
|
|
95
|
+
|
|
96
|
+
def read_str() -> str:
|
|
97
|
+
(n,) = struct.unpack("<Q", f.read(8))
|
|
98
|
+
return f.read(n).decode("utf-8", errors="replace")
|
|
99
|
+
|
|
100
|
+
def read_value(t: int) -> Any:
|
|
101
|
+
if t == 8:
|
|
102
|
+
return read_str()
|
|
103
|
+
if t == 9: # array
|
|
104
|
+
(et,) = struct.unpack("<I", f.read(4))
|
|
105
|
+
(n,) = struct.unpack("<Q", f.read(8))
|
|
106
|
+
for _ in range(n):
|
|
107
|
+
read_value(et)
|
|
108
|
+
return None
|
|
109
|
+
fmt = _GGUF_TYPES[t]
|
|
110
|
+
return struct.unpack("<" + fmt, f.read(struct.calcsize(fmt)))[0]
|
|
111
|
+
|
|
112
|
+
for _ in range(n_kv):
|
|
113
|
+
key = read_str()
|
|
114
|
+
(t,) = struct.unpack("<I", f.read(4))
|
|
115
|
+
val = read_value(t)
|
|
116
|
+
if val is not None and (wanted is None or key in wanted):
|
|
117
|
+
out[key] = val
|
|
118
|
+
if wanted is not None and wanted <= out.keys():
|
|
119
|
+
break
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gguf_base_model(path: str | Path) -> str | None:
|
|
124
|
+
"""The `org/repo` of the model this GGUF was made from, when the file says so."""
|
|
125
|
+
keys = {"general.base_model.0.repo_url", "general.base_model.0.name"}
|
|
126
|
+
try:
|
|
127
|
+
meta = read_gguf_metadata(path, keys)
|
|
128
|
+
except (OSError, ResolveError, struct.error, KeyError):
|
|
129
|
+
return None
|
|
130
|
+
url = meta.get("general.base_model.0.repo_url", "")
|
|
131
|
+
m = re.search(r"huggingface\.co/([^/]+/[^/?#]+)", url)
|
|
132
|
+
if m:
|
|
133
|
+
return m.group(1)
|
|
134
|
+
return meta.get("general.base_model.0.name")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ resolve
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _dir_format(path: Path) -> str:
|
|
141
|
+
cfg = path / "config.json"
|
|
142
|
+
if cfg.is_file() and '"quantization"' in cfg.read_text(encoding="utf-8", errors="ignore"):
|
|
143
|
+
return "mlx"
|
|
144
|
+
return "hf"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _check_disk(size: int, cache_dir: str | None) -> None:
|
|
148
|
+
from huggingface_hub import constants
|
|
149
|
+
|
|
150
|
+
where = cache_dir or constants.HF_HUB_CACHE
|
|
151
|
+
free = free_disk_bytes(where)
|
|
152
|
+
need = int(size * 1.1)
|
|
153
|
+
if free < need:
|
|
154
|
+
raise ResolveError(
|
|
155
|
+
f"Not enough disk space in {where}: need about {human_size(need)}, "
|
|
156
|
+
f"{human_size(free)} free. Set HF_HOME to a bigger drive."
|
|
157
|
+
)
|
|
158
|
+
print(f"Downloading to {where} ({human_size(size)}, {human_size(free)} free)")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_quant(
|
|
162
|
+
quant: str,
|
|
163
|
+
*,
|
|
164
|
+
base_url: str | None = None,
|
|
165
|
+
model_name: str | None = None,
|
|
166
|
+
compute_hash: bool = True,
|
|
167
|
+
) -> ResolvedSource:
|
|
168
|
+
"""Find a quant. Downloads from the Hub when given an hf:// URL."""
|
|
169
|
+
if quant.startswith("openai://"):
|
|
170
|
+
if not base_url:
|
|
171
|
+
raise ResolveError("openai:// needs --base-url, for example http://localhost:8000/v1")
|
|
172
|
+
name = quant[len("openai://") :] or (model_name or "")
|
|
173
|
+
return ResolvedSource(
|
|
174
|
+
source=quant,
|
|
175
|
+
label=name or "server-model",
|
|
176
|
+
format="openai",
|
|
177
|
+
extra={"model_name": model_name or name},
|
|
178
|
+
)
|
|
179
|
+
if quant.startswith("hf://"):
|
|
180
|
+
ref = parse_hf_url(quant)
|
|
181
|
+
return _resolve_hf(ref, quant)
|
|
182
|
+
p = Path(quant).expanduser()
|
|
183
|
+
if p.is_file():
|
|
184
|
+
src = ResolvedSource(
|
|
185
|
+
source=str(p),
|
|
186
|
+
label=label_from_filename(p.name) if p.suffix == ".gguf" else p.stem,
|
|
187
|
+
path=p,
|
|
188
|
+
size_bytes=p.stat().st_size,
|
|
189
|
+
format="gguf" if p.suffix.lower() == ".gguf" else "hf",
|
|
190
|
+
)
|
|
191
|
+
if src.format == "gguf":
|
|
192
|
+
src.base_model = gguf_base_model(p)
|
|
193
|
+
if compute_hash:
|
|
194
|
+
src.sha256 = sha256_file(p)
|
|
195
|
+
return src
|
|
196
|
+
if p.is_dir():
|
|
197
|
+
size = sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
|
|
198
|
+
return ResolvedSource(
|
|
199
|
+
source=str(p), label=p.name, path=p, size_bytes=size, format=_dir_format(p)
|
|
200
|
+
)
|
|
201
|
+
raise ResolveError(
|
|
202
|
+
f"Cannot find {quant!r}. Use a local file or folder, hf://org/repo/file.gguf, "
|
|
203
|
+
"or openai://NAME with --base-url."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_hf(ref: HFRef, original: str) -> ResolvedSource:
|
|
208
|
+
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
|
209
|
+
|
|
210
|
+
api = HfApi()
|
|
211
|
+
if ref.path and ref.path.endswith(".gguf"):
|
|
212
|
+
info = api.get_paths_info(ref.repo, [ref.path])
|
|
213
|
+
if not info:
|
|
214
|
+
raise ResolveError(f"{ref.path} was not found in {ref.repo}.")
|
|
215
|
+
size = info[0].size or 0
|
|
216
|
+
sha = info[0].lfs.sha256 if getattr(info[0], "lfs", None) else None
|
|
217
|
+
_check_disk(size, None)
|
|
218
|
+
local = Path(hf_hub_download(ref.repo, ref.path))
|
|
219
|
+
if sha is None:
|
|
220
|
+
sha = sha256_file(local)
|
|
221
|
+
return ResolvedSource(
|
|
222
|
+
source=f"hf://{ref.repo}/{ref.path}",
|
|
223
|
+
label=label_from_filename(ref.path),
|
|
224
|
+
path=local,
|
|
225
|
+
size_bytes=size or local.stat().st_size,
|
|
226
|
+
format="gguf",
|
|
227
|
+
publisher=ref.repo.split("/")[0],
|
|
228
|
+
base_model=gguf_base_model(local),
|
|
229
|
+
sha256=sha,
|
|
230
|
+
)
|
|
231
|
+
if ref.path:
|
|
232
|
+
raise ResolveError(
|
|
233
|
+
f"{original!r}: only .gguf files or whole repos (for MLX or Transformers) are supported."
|
|
234
|
+
)
|
|
235
|
+
# whole repo: MLX or Transformers folder
|
|
236
|
+
files = api.model_info(ref.repo, files_metadata=True).siblings or []
|
|
237
|
+
size = sum(s.size or 0 for s in files if s.rfilename.endswith((".safetensors", ".bin")))
|
|
238
|
+
_check_disk(size, None)
|
|
239
|
+
local = Path(snapshot_download(ref.repo))
|
|
240
|
+
return ResolvedSource(
|
|
241
|
+
source=f"hf://{ref.repo}",
|
|
242
|
+
label=ref.repo.split("/")[-1],
|
|
243
|
+
path=local,
|
|
244
|
+
size_bytes=size,
|
|
245
|
+
format=_dir_format(local),
|
|
246
|
+
publisher=ref.repo.split("/")[0],
|
|
247
|
+
)
|
quantgap/hub/submit.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Send a result to the shared results dataset as a pull request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from quantgap.models import RESULT_SCHEMA, ScoreResult, Submission
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SubmitError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_result(path: str | Path) -> ScoreResult:
|
|
16
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
17
|
+
if data.get("schema") != RESULT_SCHEMA:
|
|
18
|
+
raise SubmitError(f"{path} is not a quantgap result (schema {data.get('schema')!r}).")
|
|
19
|
+
return ScoreResult.model_validate(data)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_submission(result: ScoreResult) -> Submission:
|
|
23
|
+
"""Check a result is submittable and describe exactly what would be sent."""
|
|
24
|
+
if result.quick:
|
|
25
|
+
raise SubmitError(
|
|
26
|
+
"This is a --quick result. Run without --quick to get a submittable result."
|
|
27
|
+
)
|
|
28
|
+
if not result.quant.sha256:
|
|
29
|
+
raise SubmitError(
|
|
30
|
+
"This result has no file hash, so nobody could re-check it. Score a GGUF file or a "
|
|
31
|
+
"downloaded model folder, not a bare server."
|
|
32
|
+
)
|
|
33
|
+
path = f"results/{result.pack_id}/{result.quant.sha256}.json"
|
|
34
|
+
m = result.metrics
|
|
35
|
+
d = m.damage
|
|
36
|
+
lines = [
|
|
37
|
+
f"Result for **{result.quant.label}** ({result.quant.source})",
|
|
38
|
+
"",
|
|
39
|
+
f"- pack: `{result.pack_name or result.pack_id}` (id `{result.pack_id}`)",
|
|
40
|
+
f"- quant file SHA-256: `{result.quant.sha256}`",
|
|
41
|
+
f"- backend: {result.backend.name} ({result.backend.version})",
|
|
42
|
+
f"- hardware: {result.hardware}",
|
|
43
|
+
f"- KLD mean: {m.kld_mean:.5f} (95% CI {m.kld_mean_ci95[0]:.5f} to {m.kld_mean_ci95[1]:.5f})",
|
|
44
|
+
f"- top-1 agreement: {m.top1_agree * 100:.2f}%",
|
|
45
|
+
f"- approximate (server top-N): {'yes' if result.approximate else 'no'}",
|
|
46
|
+
]
|
|
47
|
+
if d:
|
|
48
|
+
lines.append(
|
|
49
|
+
f"- loops: {d.loops} (reference {d.loops_ref}), wrong script: {d.wrong_script}, "
|
|
50
|
+
f"valid JSON: {d.json_valid}/{d.json_n} (reference {d.json_valid_ref})"
|
|
51
|
+
)
|
|
52
|
+
lines += [
|
|
53
|
+
"",
|
|
54
|
+
"To re-check: `quantgap score <same file> --pack <same pack>` should give a KLD mean within 2%.",
|
|
55
|
+
"",
|
|
56
|
+
"Submitted with quantgap " + result.quantgap + ".",
|
|
57
|
+
]
|
|
58
|
+
return Submission(
|
|
59
|
+
path=path,
|
|
60
|
+
pr_title=f"Add result: {result.quant.label} on {result.pack_name or result.pack_id}",
|
|
61
|
+
pr_body="\n".join(lines),
|
|
62
|
+
result=result,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def submit(
|
|
67
|
+
result_file: str | Path,
|
|
68
|
+
results_repo: str,
|
|
69
|
+
dry_run: bool = False,
|
|
70
|
+
token: str | None = None,
|
|
71
|
+
) -> tuple[Submission, str | None]:
|
|
72
|
+
"""Returns (submission, PR url). With dry_run nothing is sent and the URL is None."""
|
|
73
|
+
sub = build_submission(load_result(result_file))
|
|
74
|
+
if dry_run:
|
|
75
|
+
return sub, None
|
|
76
|
+
from huggingface_hub import CommitOperationAdd, HfApi
|
|
77
|
+
|
|
78
|
+
api = HfApi(token=token)
|
|
79
|
+
info = api.create_commit(
|
|
80
|
+
repo_id=results_repo,
|
|
81
|
+
repo_type="dataset",
|
|
82
|
+
operations=[CommitOperationAdd(sub.path, sub.result.to_json().encode("utf-8"))],
|
|
83
|
+
commit_message=sub.pr_title,
|
|
84
|
+
commit_description=sub.pr_body,
|
|
85
|
+
create_pr=True,
|
|
86
|
+
)
|
|
87
|
+
return sub, info.pr_url
|
quantgap/models.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Typed data models. Pydantic validates JSON files and gives us JSON Schema for free."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
PACK_SCHEMA = "quantgap.pack/1"
|
|
12
|
+
RESULT_SCHEMA = "quantgap.result/1"
|
|
13
|
+
|
|
14
|
+
GROUPS = ("general", "chat", "code", "math", "multilingual", "json")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Model(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --------------------------------------------------------------------------- probes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProbeSeq(_Model):
|
|
25
|
+
"""One row of the probe set (the texts every model is scored on)."""
|
|
26
|
+
|
|
27
|
+
seq_id: int
|
|
28
|
+
group: Literal["general", "chat", "code", "math", "multilingual", "json"]
|
|
29
|
+
kind: Literal["text", "chat"] = "text"
|
|
30
|
+
lang: str = "en"
|
|
31
|
+
text: str | None = None # kind == "text"
|
|
32
|
+
messages: list[dict[str, str]] | None = None # kind == "chat"
|
|
33
|
+
license: str
|
|
34
|
+
source: str
|
|
35
|
+
# Used by the damage checks. For text rows the prompt is the first words of the text.
|
|
36
|
+
greedy: bool = False
|
|
37
|
+
greedy_prompt: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- pack manifest
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ModelRef(_Model):
|
|
44
|
+
repo: str
|
|
45
|
+
revision: str | None = None
|
|
46
|
+
dtype: str = "bfloat16"
|
|
47
|
+
license: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TokenizerInfo(_Model):
|
|
51
|
+
sha256: str | None = None
|
|
52
|
+
vocab_size: int
|
|
53
|
+
bos: bool = True
|
|
54
|
+
bos_id: int | None = None
|
|
55
|
+
eos_id: int | None = None
|
|
56
|
+
check_ids: list[int] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProbesInfo(_Model):
|
|
60
|
+
set: str
|
|
61
|
+
sha256: str
|
|
62
|
+
n_sequences: int
|
|
63
|
+
n_positions: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GreedyInfo(_Model):
|
|
67
|
+
n_prompts: int
|
|
68
|
+
max_new_tokens: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunnerInfo(_Model):
|
|
72
|
+
quantgap: str
|
|
73
|
+
torch: str | None = None
|
|
74
|
+
transformers: str | None = None
|
|
75
|
+
device: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Manifest(_Model):
|
|
79
|
+
schema_: str = Field(default=PACK_SCHEMA, alias="schema")
|
|
80
|
+
model: ModelRef
|
|
81
|
+
tokenizer: TokenizerInfo
|
|
82
|
+
probes: ProbesInfo
|
|
83
|
+
chat_template_sha256: str | None = None
|
|
84
|
+
k: int
|
|
85
|
+
logp_dtype: Literal["float16", "float32"] = "float32"
|
|
86
|
+
greedy: GreedyInfo
|
|
87
|
+
runner: RunnerInfo
|
|
88
|
+
files: dict[str, str] = Field(default_factory=dict)
|
|
89
|
+
created_at: str
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
92
|
+
|
|
93
|
+
def canonical_json(self) -> str:
|
|
94
|
+
data = self.model_dump(by_alias=True, mode="json")
|
|
95
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def pack_id(self) -> str:
|
|
99
|
+
"""First 16 hex chars of SHA-256 over the canonical manifest."""
|
|
100
|
+
return hashlib.sha256(self.canonical_json().encode("utf-8")).hexdigest()[:16]
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def short_name(self) -> str:
|
|
104
|
+
return f"{self.model.repo.split('/')[-1]}@{self.pack_id}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- results
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class QuantInfo(_Model):
|
|
111
|
+
source: str
|
|
112
|
+
sha256: str | None = None
|
|
113
|
+
size_bytes: int
|
|
114
|
+
label: str
|
|
115
|
+
publisher: str | None = None
|
|
116
|
+
format: str = "gguf"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BackendInfo(_Model):
|
|
120
|
+
name: str
|
|
121
|
+
version: str
|
|
122
|
+
settings: dict[str, Any] = Field(default_factory=dict)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class GroupMetrics(_Model):
|
|
126
|
+
kld_mean: float
|
|
127
|
+
top1_agree: float
|
|
128
|
+
n_positions: int = 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DamageMetrics(_Model):
|
|
132
|
+
"""Every number is stored next to the reference's own number (section 7.4)."""
|
|
133
|
+
|
|
134
|
+
n_prompts: int
|
|
135
|
+
loops: int
|
|
136
|
+
loops_ref: int
|
|
137
|
+
loop_rate: float
|
|
138
|
+
wrong_script: int
|
|
139
|
+
wrong_script_ref: int
|
|
140
|
+
wrong_script_rate: float
|
|
141
|
+
json_valid: int
|
|
142
|
+
json_valid_ref: int
|
|
143
|
+
json_n: int
|
|
144
|
+
json_valid_rate: float
|
|
145
|
+
first_divergence_median: float
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Metrics(_Model):
|
|
149
|
+
kld_mean: float
|
|
150
|
+
kld_mean_ci95: tuple[float, float]
|
|
151
|
+
kld_p99: float
|
|
152
|
+
kld_p999: float
|
|
153
|
+
top1_agree: float
|
|
154
|
+
ppl_ratio: float
|
|
155
|
+
n_positions: int
|
|
156
|
+
n_sequences: int
|
|
157
|
+
clamp_count: int = 0
|
|
158
|
+
by_group: dict[str, GroupMetrics] = Field(default_factory=dict)
|
|
159
|
+
damage: DamageMetrics | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ScoreResult(_Model):
|
|
163
|
+
schema_: str = Field(default=RESULT_SCHEMA, alias="schema")
|
|
164
|
+
pack_id: str
|
|
165
|
+
pack_name: str | None = None
|
|
166
|
+
quant: QuantInfo
|
|
167
|
+
backend: BackendInfo
|
|
168
|
+
metrics: Metrics
|
|
169
|
+
hardware: str
|
|
170
|
+
quantgap: str
|
|
171
|
+
created_at: str
|
|
172
|
+
quick: bool = False
|
|
173
|
+
approximate: bool = False
|
|
174
|
+
nondeterministic: bool = False
|
|
175
|
+
warnings: list[str] = Field(default_factory=list)
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
178
|
+
|
|
179
|
+
def to_json(self) -> str:
|
|
180
|
+
return json.dumps(self.model_dump(by_alias=True, mode="json"), indent=2, ensure_ascii=False)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def submittable(self) -> bool:
|
|
184
|
+
return not self.quick and self.quant.sha256 is not None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class CompareReport(_Model):
|
|
188
|
+
pack_id: str
|
|
189
|
+
pack_name: str | None = None
|
|
190
|
+
results: list[ScoreResult]
|
|
191
|
+
budget_bytes: int | None = None
|
|
192
|
+
recommendation: str | None = None
|
|
193
|
+
assumptions: str | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Submission(_Model):
|
|
197
|
+
"""What `quantgap submit` writes into the results dataset."""
|
|
198
|
+
|
|
199
|
+
path: str
|
|
200
|
+
pr_title: str
|
|
201
|
+
pr_body: str
|
|
202
|
+
result: ScoreResult
|