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/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}"
@@ -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
@@ -0,0 +1,4 @@
1
+ from quantgap.pack.reader import Pack, PackIntegrityError, SeqTopK
2
+ from quantgap.pack.writer import PackWriter
3
+
4
+ __all__ = ["Pack", "PackIntegrityError", "PackWriter", "SeqTopK"]