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.
@@ -0,0 +1,78 @@
1
+ """Pick the best quant that fits a memory budget (section 7.5 of the spec)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from quantgap.models import ScoreResult
8
+ from quantgap.util import GIB, human_size
9
+
10
+ OVERHEAD_FACTOR = 1.1
11
+ OVERHEAD_BYTES = GIB
12
+ ASSUMPTION = (
13
+ "Memory estimate = file size x 1.1 + 1 GiB (context buffers at 2K context). "
14
+ "Real use depends on your engine and context length."
15
+ )
16
+ DAMAGE_WARN_GAP = 3 # prompts worse than the reference
17
+
18
+
19
+ def estimated_memory(size_bytes: int) -> float:
20
+ return size_bytes * OVERHEAD_FACTOR + OVERHEAD_BYTES
21
+
22
+
23
+ def _overlap(a: ScoreResult, b: ScoreResult) -> bool:
24
+ lo1, hi1 = a.metrics.kld_mean_ci95
25
+ lo2, hi2 = b.metrics.kld_mean_ci95
26
+ return lo1 <= hi2 and lo2 <= hi1
27
+
28
+
29
+ @dataclass
30
+ class Recommendation:
31
+ sentence: str
32
+ chosen: ScoreResult | None
33
+ warnings: list[str] = field(default_factory=list)
34
+ not_clearly_different: list[ScoreResult] = field(default_factory=list)
35
+
36
+
37
+ def _name(r: ScoreResult) -> str:
38
+ return r.quant.label
39
+
40
+
41
+ def recommend(results: list[ScoreResult], budget_bytes: int) -> Recommendation:
42
+ budget_txt = human_size(budget_bytes)
43
+ fits = [r for r in results if estimated_memory(r.quant.size_bytes) <= budget_bytes]
44
+ if not fits:
45
+ smallest = min(results, key=lambda r: r.quant.size_bytes)
46
+ need = estimated_memory(smallest.quant.size_bytes)
47
+ return Recommendation(
48
+ f"Nothing fits in {budget_txt}. The smallest, {_name(smallest)}, needs about "
49
+ f"{human_size(need)}.",
50
+ None,
51
+ )
52
+ best = min(fits, key=lambda r: r.metrics.kld_mean)
53
+ ties = [r for r in fits if r is not best and _overlap(r, best)]
54
+ pool = [best, *ties]
55
+ chosen = min(pool, key=lambda r: (r.quant.size_bytes, r.metrics.kld_mean))
56
+ size = human_size(chosen.quant.size_bytes)
57
+ sentence = f"For {budget_txt}, use {_name(chosen)} ({size})."
58
+ if chosen is not best:
59
+ saved = best.quant.size_bytes - chosen.quant.size_bytes
60
+ sentence += (
61
+ f" It is not clearly different from {_name(best)}, which has the lowest KLD, "
62
+ f"and is {human_size(saved)} smaller."
63
+ )
64
+ elif ties:
65
+ names = ", ".join(_name(t) for t in ties)
66
+ sentence += f" {names} are not clearly different but are larger."
67
+ warnings: list[str] = []
68
+ d = chosen.metrics.damage
69
+ if d is not None:
70
+ if d.loops - d.loops_ref >= DAMAGE_WARN_GAP:
71
+ warnings.append(
72
+ f"Warning: {_name(chosen)} loops in {d.loops} prompts, the original in {d.loops_ref}."
73
+ )
74
+ if d.wrong_script - d.wrong_script_ref >= DAMAGE_WARN_GAP:
75
+ warnings.append(
76
+ f"Warning: {_name(chosen)} writes the wrong script in {d.wrong_script} prompts."
77
+ )
78
+ return Recommendation(sentence, chosen, warnings, ties)
quantgap/score/run.py ADDED
@@ -0,0 +1,260 @@
1
+ """Score one quant against one pack. Backend-agnostic: everything backend-specific is behind the
2
+ `Backend` interface."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import math
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+ from quantgap import __version__
13
+ from quantgap.backends.base import Backend, BackendSettings, ResolvedSource
14
+ from quantgap.models import (
15
+ BackendInfo,
16
+ DamageMetrics,
17
+ GroupMetrics,
18
+ Metrics,
19
+ QuantInfo,
20
+ ScoreResult,
21
+ )
22
+ from quantgap.pack.reader import GreedyRow, Pack
23
+ from quantgap.score import damage as dmg
24
+ from quantgap.score.kld import kld_topk, ppl_ratio
25
+ from quantgap.score.stats import bootstrap_mean_ci, percentile
26
+ from quantgap.tokcheck import TokenizerMismatch, check_tokenizer
27
+ from quantgap.util import hardware_string, jsonable_float, utc_now
28
+
29
+ QUICK_FRACTION = 0.25
30
+ QUICK_GREEDY_PER_GROUP = 2
31
+ NONDETERMINISM_TOL = 1e-3
32
+
33
+ Progress = Callable[[str, int, int], None]
34
+
35
+
36
+ class ScoreError(RuntimeError):
37
+ """A clear, user-facing reason scoring cannot run."""
38
+
39
+
40
+ @dataclass
41
+ class _SeqScore:
42
+ group: str
43
+ kl: np.ndarray
44
+ agree: np.ndarray
45
+ lq_target: np.ndarray
46
+ lp_target: np.ndarray
47
+
48
+
49
+ def select_sequences(pack: Pack, groups: list[str] | None, quick: bool) -> list[int]:
50
+ """Sequence ids to score. --groups filters; --quick keeps the first 25% of each group."""
51
+ rows = pack.probes()
52
+ by_group: dict[str, list[int]] = {}
53
+ for r in rows:
54
+ by_group.setdefault(r.group, []).append(r.seq_id)
55
+ chosen: list[int] = []
56
+ for g, ids in by_group.items():
57
+ if groups and g not in groups:
58
+ continue
59
+ ids = sorted(ids)
60
+ if quick:
61
+ ids = ids[: max(1, math.ceil(len(ids) * QUICK_FRACTION))]
62
+ chosen += ids
63
+ if not chosen:
64
+ raise ScoreError(
65
+ f"No sequences match --groups {groups}. Groups in this pack: {sorted(by_group)}."
66
+ )
67
+ return chosen
68
+
69
+
70
+ def check_context(pack: Pack, settings: BackendSettings, seq_ids: list[int]) -> None:
71
+ longest = max(len(r.token_ids) for r in pack.probes() if r.seq_id in set(seq_ids))
72
+ if settings.ctx < longest + 8:
73
+ raise ScoreError(
74
+ f"--ctx {settings.ctx} is too small. The longest probe has {longest} tokens, "
75
+ f"so use --ctx {longest + 8} or more (we suggest 2048)."
76
+ )
77
+
78
+
79
+ def run_damage(
80
+ backend: Backend, pack: Pack, quick: bool, progress: Progress | None
81
+ ) -> DamageMetrics | None:
82
+ rows: list[GreedyRow] = pack.greedy()
83
+ if quick:
84
+ seen: dict[str, int] = {}
85
+ keep = []
86
+ for r in rows:
87
+ seen[r.group] = seen.get(r.group, 0) + 1
88
+ if seen[r.group] <= QUICK_GREEDY_PER_GROUP:
89
+ keep.append(r)
90
+ rows = keep
91
+ max_new = pack.manifest.greedy.max_new_tokens
92
+ loops = loops_ref = ws = ws_ref = jv = jv_ref = jn = 0
93
+ divs: list[int] = []
94
+ try:
95
+ for i, r in enumerate(rows):
96
+ if progress:
97
+ progress("generating", i, len(rows))
98
+ q_ids = backend.generate_greedy(list(r.prompt_ids), max_new)
99
+ q_text = backend.decode(q_ids)
100
+ divs.append(dmg.first_divergence(q_ids, r.ref_ids))
101
+ loops += dmg.is_loop(q_ids)
102
+ loops_ref += dmg.is_loop(r.ref_ids)
103
+ if r.group in dmg.SCRIPT_CHECK_GROUPS:
104
+ ws += dmg.has_wrong_script(q_text, r.ref_text)
105
+ # The reference is compared with itself, so it flags nothing by definition.
106
+ if r.group == "json":
107
+ jn += 1
108
+ jv += dmg.json_valid(q_text)
109
+ jv_ref += dmg.json_valid(r.ref_text)
110
+ except Exception as e: # a backend that cannot generate should not lose the KLD result
111
+ raise ScoreError(f"Visible-damage checks failed: {e}. Use --no-damage to skip them.") from e
112
+ n = max(1, len(rows))
113
+ return DamageMetrics(
114
+ n_prompts=len(rows),
115
+ loops=int(loops),
116
+ loops_ref=int(loops_ref),
117
+ loop_rate=loops / n,
118
+ wrong_script=int(ws),
119
+ wrong_script_ref=int(ws_ref),
120
+ wrong_script_rate=ws / n,
121
+ json_valid=int(jv),
122
+ json_valid_ref=int(jv_ref),
123
+ json_n=jn,
124
+ json_valid_rate=(jv / jn) if jn else 0.0,
125
+ first_divergence_median=dmg.median_or_zero(divs),
126
+ )
127
+
128
+
129
+ def score_quant(
130
+ pack: Pack,
131
+ backend: Backend,
132
+ source: ResolvedSource,
133
+ settings: BackendSettings,
134
+ *,
135
+ groups: list[str] | None = None,
136
+ quick: bool = False,
137
+ do_damage: bool = True,
138
+ warnings: list[str] | None = None,
139
+ progress: Progress | None = None,
140
+ ) -> ScoreResult:
141
+ warnings = list(warnings or [])
142
+ manifest = pack.manifest
143
+ topk = pack.topk()
144
+ probes = {r.seq_id: r for r in pack.probes()}
145
+ seq_ids = select_sequences(pack, groups, quick)
146
+ check_context(pack, settings, seq_ids)
147
+
148
+ # Tokenizer check (never silently fixed).
149
+ fp = backend.tokenizer_fingerprint()
150
+ max_id = max(int(t.ids.max()) for t in topk.values() if len(t.ids))
151
+ try:
152
+ check_tokenizer(manifest.tokenizer, fp, max_id)
153
+ except TokenizerMismatch as e:
154
+ raise ScoreError(str(e)) from e
155
+
156
+ # E6: run the first sequence twice and see if the numbers move.
157
+ nondeterministic = False
158
+ first = next(s for s in seq_ids if len(topk[s].pos))
159
+ a = backend.prompt_logprobs(probes[first].token_ids.tolist(), topk[first].pos, topk[first].ids)
160
+ b = backend.prompt_logprobs(probes[first].token_ids.tolist(), topk[first].pos, topk[first].ids)
161
+ both = ~np.isnan(a.gathered) & ~np.isnan(b.gathered)
162
+ if both.any() and np.max(np.abs(a.gathered[both] - b.gathered[both])) > NONDETERMINISM_TOL:
163
+ nondeterministic = True
164
+ warnings.append("Running the same text twice gave different numbers (non-deterministic).")
165
+
166
+ scores: list[_SeqScore] = []
167
+ approximate = False
168
+ clamps = 0
169
+ for i, sid in enumerate(seq_ids):
170
+ if progress:
171
+ progress("scoring", i, len(seq_ids))
172
+ t = topk[sid]
173
+ if len(t.pos) == 0:
174
+ continue
175
+ pl = backend.prompt_logprobs(probes[sid].token_ids.tolist(), t.pos, t.ids)
176
+ approximate |= pl.approximate
177
+ res = kld_topk(t.logp, pl.gathered)
178
+ clamps += res.clamps
179
+ scores.append(
180
+ _SeqScore(
181
+ probes[sid].group,
182
+ res.kl,
183
+ pl.top1 == t.ids[:, 0],
184
+ pl.target,
185
+ t.logp_target,
186
+ )
187
+ )
188
+ if progress:
189
+ progress("scoring", len(seq_ids), len(seq_ids))
190
+
191
+ kl = np.concatenate([s.kl for s in scores])
192
+ agree = np.concatenate([s.agree for s in scores])
193
+ lq = np.concatenate([s.lq_target for s in scores])
194
+ lp = np.concatenate([s.lp_target for s in scores])
195
+ sums = np.array([s.kl.sum() for s in scores])
196
+ counts = np.array([len(s.kl) for s in scores])
197
+ ci = bootstrap_mean_ci(sums, counts)
198
+
199
+ by_group: dict[str, GroupMetrics] = {}
200
+ for g in dict.fromkeys(s.group for s in scores):
201
+ sel = [s for s in scores if s.group == g]
202
+ gk = np.concatenate([s.kl for s in sel])
203
+ ga = np.concatenate([s.agree for s in sel])
204
+ by_group[g] = GroupMetrics(
205
+ kld_mean=jsonable_float(float(gk.mean())),
206
+ top1_agree=jsonable_float(float(ga.mean())),
207
+ n_positions=len(gk),
208
+ )
209
+
210
+ if clamps > max(10, 0.01 * len(kl)):
211
+ warnings.append(
212
+ f"{clamps} probabilities were raised to 1e-12 before taking logs. A high number "
213
+ "means something is broken (wrong tokenizer or a damaged model)."
214
+ )
215
+ if approximate:
216
+ warnings.append(
217
+ "approximate (server top-N): the server returned only its own top-N, so some "
218
+ "reference ids were counted in the tail bucket."
219
+ )
220
+
221
+ damage_metrics = None
222
+ if do_damage:
223
+ damage_metrics = run_damage(backend, pack, quick, progress)
224
+
225
+ metrics = Metrics(
226
+ kld_mean=jsonable_float(float(kl.mean())),
227
+ kld_mean_ci95=(jsonable_float(ci[0]), jsonable_float(ci[1])),
228
+ kld_p99=jsonable_float(percentile(kl, 99)),
229
+ kld_p999=jsonable_float(percentile(kl, 99.9)),
230
+ top1_agree=jsonable_float(float(agree.mean())),
231
+ ppl_ratio=jsonable_float(ppl_ratio(lq, lp)),
232
+ n_positions=len(kl),
233
+ n_sequences=len(scores),
234
+ clamp_count=clamps,
235
+ by_group=by_group,
236
+ damage=damage_metrics,
237
+ )
238
+ return ScoreResult(
239
+ pack_id=pack.pack_id,
240
+ pack_name=manifest.short_name,
241
+ quant=QuantInfo(
242
+ source=source.source,
243
+ sha256=source.sha256,
244
+ size_bytes=source.size_bytes,
245
+ label=source.label,
246
+ publisher=source.publisher,
247
+ format=source.format,
248
+ ),
249
+ backend=BackendInfo(
250
+ name=backend.name, version=backend.version(), settings=settings.public()
251
+ ),
252
+ metrics=metrics,
253
+ hardware=hardware_string(),
254
+ quantgap=__version__,
255
+ created_at=utc_now(),
256
+ quick=quick,
257
+ approximate=approximate,
258
+ nondeterministic=nondeterministic,
259
+ warnings=warnings,
260
+ )
@@ -0,0 +1,33 @@
1
+ """Percentiles and a bootstrap confidence interval that resamples whole sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ N_BOOT = 1000
8
+ SEED = 0
9
+
10
+
11
+ def percentile(x: np.ndarray, q: float) -> float:
12
+ return float(np.percentile(x, q)) if len(x) else float("nan")
13
+
14
+
15
+ def bootstrap_mean_ci(
16
+ seq_sums: np.ndarray, seq_counts: np.ndarray, n_boot: int = N_BOOT, seed: int = SEED
17
+ ) -> tuple[float, float]:
18
+ """95% interval for the mean per-position value, resampling *sequences*.
19
+
20
+ Positions inside one sequence are not independent, so resampling positions would give an
21
+ interval that is too narrow. `seq_sums[i]` is the sum of the value over sequence i and
22
+ `seq_counts[i]` its number of positions.
23
+ """
24
+ seq_sums = np.asarray(seq_sums, dtype=np.float64)
25
+ seq_counts = np.asarray(seq_counts, dtype=np.float64)
26
+ n = len(seq_sums)
27
+ if n == 0:
28
+ return (float("nan"), float("nan"))
29
+ rng = np.random.default_rng(seed)
30
+ idx = rng.integers(0, n, size=(n_boot, n))
31
+ means = seq_sums[idx].sum(axis=1) / seq_counts[idx].sum(axis=1)
32
+ lo, hi = np.percentile(means, [2.5, 97.5])
33
+ return (float(lo), float(hi))
quantgap/tokcheck.py ADDED
@@ -0,0 +1,71 @@
1
+ """Tokenizer compatibility check.
2
+
3
+ A quant can only be compared to a pack if both use the same token ids. We check this before
4
+ scoring and stop with a clear error if it fails. We never "fix" a mismatch silently.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from quantgap.models import TokenizerInfo
12
+
13
+ # Mixed scripts, code, numbers, emoji, odd spacing. Special tokens are checked by id, not by text.
14
+ CHECK_STRING = (
15
+ "Hello, world! 你好,世界。 नमस्ते दुनिया. مرحبا بالعالم. ¿Qué tal?\n"
16
+ "def f(x):\n return {'a': [1, 2.5e-3, None]} # 😀🚀\n"
17
+ " two spaces\ttab 12345 3.14159 https://example.com/a?b=c&d=e\n"
18
+ )
19
+
20
+
21
+ class TokenizerMismatch(RuntimeError):
22
+ """The quant's tokenizer is not the same as the pack's."""
23
+
24
+
25
+ @dataclass
26
+ class TokFingerprint:
27
+ vocab_size: int
28
+ bos_id: int | None
29
+ eos_id: int | None
30
+ check_ids: list[int] = field(default_factory=list)
31
+
32
+
33
+ def check_tokenizer(pack_tok: TokenizerInfo, quant: TokFingerprint, pack_top_ids_max: int) -> None:
34
+ """Raise TokenizerMismatch if the quant cannot be compared to the pack.
35
+
36
+ `pack_top_ids_max` is the largest token id seen anywhere in the pack's top-k lists and targets.
37
+ A quant may have extra padding ids beyond the reference vocabulary if those never appear there.
38
+ """
39
+ # 1. vocabulary size
40
+ # A server cannot tell us its vocabulary size (vocab_size <= 0): skip this one check.
41
+ if quant.vocab_size > 0 and quant.vocab_size != pack_tok.vocab_size:
42
+ if quant.vocab_size < pack_tok.vocab_size:
43
+ raise TokenizerMismatch(
44
+ f"Check 1 (vocab size) failed: quant has {quant.vocab_size} tokens, "
45
+ f"pack expects {pack_tok.vocab_size}."
46
+ )
47
+ if pack_top_ids_max >= pack_tok.vocab_size: # pragma: no cover - defensive
48
+ raise TokenizerMismatch(
49
+ "Check 1 (vocab size) failed: pack ids exceed its own vocabulary."
50
+ )
51
+ # extra padding ids are fine: no pack id points at them.
52
+ # 2. fixed check string
53
+ if pack_tok.check_ids and quant.check_ids != pack_tok.check_ids:
54
+ a, b = pack_tok.check_ids, quant.check_ids
55
+ i = next((j for j in range(min(len(a), len(b))) if a[j] != b[j]), min(len(a), len(b)))
56
+ got = b[i] if i < len(b) else "(end)"
57
+ want = a[i] if i < len(a) else "(end)"
58
+ raise TokenizerMismatch(
59
+ f"Check 2 (encode a fixed test string) failed at token index {i}: "
60
+ f"pack has id {want}, quant has id {got}. "
61
+ "These models do not use the same tokenizer, so scores would be meaningless."
62
+ )
63
+ # 3. BOS / EOS ids
64
+ for label, want, got in (
65
+ ("BOS", pack_tok.bos_id, quant.bos_id),
66
+ ("EOS", pack_tok.eos_id, quant.eos_id),
67
+ ):
68
+ if want is not None and got is not None and want != got:
69
+ raise TokenizerMismatch(
70
+ f"Check 3 ({label} id) failed: pack has {want}, quant has {got}."
71
+ )
quantgap/util.py ADDED
@@ -0,0 +1,95 @@
1
+ """Small helpers shared by several modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import platform
7
+ import re
8
+ import shutil
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ _SIZE_RE = re.compile(r"^\s*([0-9]*\.?[0-9]+)\s*([kmgt]?i?b?)\s*$", re.IGNORECASE)
13
+ _UNITS = {
14
+ "": 1,
15
+ "b": 1,
16
+ "k": 1000,
17
+ "kb": 1000,
18
+ "m": 1000**2,
19
+ "mb": 1000**2,
20
+ "g": 1000**3,
21
+ "gb": 1000**3,
22
+ "t": 1000**4,
23
+ "tb": 1000**4,
24
+ "ki": 1024,
25
+ "kib": 1024,
26
+ "mi": 1024**2,
27
+ "mib": 1024**2,
28
+ "gi": 1024**3,
29
+ "gib": 1024**3,
30
+ "ti": 1024**4,
31
+ "tib": 1024**4,
32
+ }
33
+ GIB = 1024**3
34
+
35
+
36
+ def parse_size(text: str) -> int:
37
+ """Parse '16GB', '16GiB', '16000MB' into bytes. GB means 10^9, GiB means 2^30."""
38
+ m = _SIZE_RE.match(text)
39
+ if not m or m.group(2).lower() not in _UNITS:
40
+ raise ValueError(f"Cannot read size {text!r}. Try something like 16GB, 16GiB or 16000MB.")
41
+ return int(float(m.group(1)) * _UNITS[m.group(2).lower()])
42
+
43
+
44
+ def human_size(n: int | float) -> str:
45
+ n = float(n)
46
+ for unit in ("B", "KB", "MB", "GB", "TB"):
47
+ if n < 1000 or unit == "TB":
48
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
49
+ n /= 1000
50
+ return f"{n:.1f} TB"
51
+
52
+
53
+ def sha256_bytes(data: bytes) -> str:
54
+ return hashlib.sha256(data).hexdigest()
55
+
56
+
57
+ def sha256_file(path: str | Path, chunk: int = 1 << 20) -> str:
58
+ h = hashlib.sha256()
59
+ with open(path, "rb") as f:
60
+ while block := f.read(chunk):
61
+ h.update(block)
62
+ return h.hexdigest()
63
+
64
+
65
+ def utc_now() -> str:
66
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
67
+
68
+
69
+ def hardware_string() -> str:
70
+ """A short description of this machine, for the result file."""
71
+ parts = [platform.processor() or platform.machine() or "unknown CPU"]
72
+ try: # optional, never required
73
+ import torch
74
+
75
+ if torch.cuda.is_available():
76
+ parts = [torch.cuda.get_device_name(0) + " / CUDA"]
77
+ elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
78
+ parts = [f"Apple {platform.machine()} / MPS"]
79
+ except Exception:
80
+ pass
81
+ if platform.system() == "Darwin" and platform.machine() == "arm64" and "Apple" not in parts[0]:
82
+ parts = ["Apple silicon / Metal"]
83
+ return f"{parts[0]} ({platform.system()})"
84
+
85
+
86
+ def free_disk_bytes(path: str | Path) -> int:
87
+ p = Path(path)
88
+ while not p.exists() and p != p.parent:
89
+ p = p.parent
90
+ return shutil.disk_usage(p).free
91
+
92
+
93
+ def jsonable_float(x: float) -> float:
94
+ """Round to 9 significant digits so result files are stable across runs."""
95
+ return float(f"{x:.9g}")