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,142 @@
1
+ """Read a pack. Every file is checked against the SHA-256 in the manifest before it is used."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pyarrow.parquet as pq
11
+
12
+ from quantgap.models import Manifest
13
+ from quantgap.util import sha256_file
14
+
15
+
16
+ class PackIntegrityError(RuntimeError):
17
+ """A pack file does not match the hash in its manifest (corrupt or edited)."""
18
+
19
+
20
+ @dataclass
21
+ class SeqTopK:
22
+ """The reference model's top-k guesses at the scored positions of one sequence."""
23
+
24
+ seq_id: int
25
+ pos: np.ndarray # (n,) position index; predicts token pos + 1
26
+ target: np.ndarray # (n,) the actual next token
27
+ ids: np.ndarray # (n, k) reference top-k ids, highest first
28
+ logp: np.ndarray # (n, k) their natural-log probabilities
29
+ logp_target: np.ndarray # (n,) reference log probability of the actual next token
30
+
31
+
32
+ @dataclass
33
+ class ProbeRow:
34
+ seq_id: int
35
+ group: str
36
+ token_ids: np.ndarray
37
+ score_mask: np.ndarray
38
+
39
+
40
+ @dataclass
41
+ class GreedyRow:
42
+ seq_id: int
43
+ group: str
44
+ lang: str
45
+ prompt_ids: list[int]
46
+ ref_ids: list[int]
47
+ ref_text: str
48
+
49
+
50
+ class Pack:
51
+ def __init__(self, root: Path, manifest: Manifest):
52
+ self.root = root
53
+ self.manifest = manifest
54
+ self._topk: dict[int, SeqTopK] | None = None
55
+
56
+ @property
57
+ def pack_id(self) -> str:
58
+ return self.manifest.pack_id
59
+
60
+ @classmethod
61
+ def load(cls, path: str | Path, verify: bool = True) -> Pack:
62
+ root = Path(path)
63
+ mf = root / "manifest.json"
64
+ if not mf.is_file():
65
+ raise FileNotFoundError(f"No manifest.json in {root}. Is this a quantgap pack folder?")
66
+ manifest = Manifest.model_validate(json.loads(mf.read_text(encoding="utf-8")))
67
+ pack = cls(root, manifest)
68
+ if verify:
69
+ pack.verify()
70
+ return pack
71
+
72
+ def verify(self) -> None:
73
+ for name, want in self.manifest.files.items():
74
+ p = self.root / name
75
+ if not p.is_file():
76
+ raise PackIntegrityError(f"Pack file missing: {name}")
77
+ got = sha256_file(p)
78
+ if got != want:
79
+ raise PackIntegrityError(
80
+ f"Hash mismatch for {name}: manifest says {want[:12]}..., file is {got[:12]}.... "
81
+ "The file was changed or the download is broken. Download the pack again."
82
+ )
83
+
84
+ def probes(self) -> list[ProbeRow]:
85
+ rows = pq.read_table(self.root / "probes.parquet").to_pylist()
86
+ return [
87
+ ProbeRow(
88
+ r["seq_id"],
89
+ r["group"],
90
+ np.array(r["token_ids"], np.int64),
91
+ np.array(r["score_mask"], bool),
92
+ )
93
+ for r in rows
94
+ ]
95
+
96
+ def greedy(self) -> list[GreedyRow]:
97
+ rows = pq.read_table(self.root / "greedy.parquet").to_pylist()
98
+ return [
99
+ GreedyRow(
100
+ r["seq_id"], r["group"], r["lang"], r["prompt_ids"], r["ref_ids"], r["ref_text"]
101
+ )
102
+ for r in rows
103
+ ]
104
+
105
+ def topk(self) -> dict[int, SeqTopK]:
106
+ if self._topk is not None:
107
+ return self._topk
108
+ k = self.manifest.k
109
+ parts: dict[int, list[dict[str, np.ndarray]]] = {}
110
+ for name in sorted(n for n in self.manifest.files if n.startswith("topk-")):
111
+ tb = pq.read_table(self.root / name)
112
+ seq = tb["seq_id"].to_numpy()
113
+ pos = tb["pos"].to_numpy()
114
+ target = tb["target"].to_numpy()
115
+ ids = tb["ids"].combine_chunks().values.to_numpy(zero_copy_only=False).reshape(-1, k)
116
+ logp = tb["logp"].combine_chunks().values.to_numpy(zero_copy_only=False).reshape(-1, k)
117
+ lpt = tb["logp_target"].to_numpy()
118
+ for s in np.unique(seq):
119
+ m = seq == s
120
+ parts.setdefault(int(s), []).append(
121
+ {
122
+ "pos": pos[m],
123
+ "target": target[m],
124
+ "ids": ids[m],
125
+ "logp": logp[m],
126
+ "lpt": lpt[m],
127
+ }
128
+ )
129
+ out: dict[int, SeqTopK] = {}
130
+ for s, chunks in parts.items():
131
+ cat = {key: np.concatenate([c[key] for c in chunks]) for key in chunks[0]}
132
+ order = np.argsort(cat["pos"], kind="stable")
133
+ out[s] = SeqTopK(
134
+ s,
135
+ cat["pos"][order],
136
+ cat["target"][order],
137
+ cat["ids"][order],
138
+ cat["logp"][order].astype(np.float64),
139
+ cat["lpt"][order].astype(np.float64),
140
+ )
141
+ self._topk = out
142
+ return out
@@ -0,0 +1,301 @@
1
+ """Run the reference model once and save its top-k next-token guesses (needs `quantgap[pack]`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from quantgap import __version__
13
+ from quantgap.models import GreedyInfo, Manifest, ModelRef, RunnerInfo, TokenizerInfo
14
+ from quantgap.pack.reader import SeqTopK
15
+ from quantgap.pack.writer import PackWriter
16
+ from quantgap.probes.registry import load_probes, probes_sha256
17
+ from quantgap.probes.render import RenderedGreedy, RenderedSeq, render_probes
18
+ from quantgap.tokcheck import CHECK_STRING
19
+
20
+ POS_CHUNK = 128 # positions per log-softmax call, keeps memory flat for 150k-token vocabularies
21
+
22
+
23
+ def tokenizer_info(tok: Any) -> TokenizerInfo:
24
+ try:
25
+ blob = tok.backend_tokenizer.to_str().encode("utf-8")
26
+ digest: str | None = hashlib.sha256(blob).hexdigest()
27
+ except Exception:
28
+ digest = None
29
+ vocab = len(tok)
30
+ bos_added = bool(tok("a", add_special_tokens=True)["input_ids"][:1] == [tok.bos_token_id])
31
+ return TokenizerInfo(
32
+ sha256=digest,
33
+ vocab_size=int(vocab),
34
+ bos=bos_added and tok.bos_token_id is not None,
35
+ bos_id=tok.bos_token_id,
36
+ eos_id=tok.eos_token_id,
37
+ check_ids=list(tok(CHECK_STRING, add_special_tokens=False)["input_ids"]),
38
+ )
39
+
40
+
41
+ def chat_template_hash(tok: Any) -> str | None:
42
+ tpl = getattr(tok, "chat_template", None)
43
+ if isinstance(tpl, str):
44
+ return hashlib.sha256(tpl.encode("utf-8")).hexdigest()
45
+ return None
46
+
47
+
48
+ def _topk_for_sequence(model: Any, seq: RenderedSeq, k: int, device: Any) -> SeqTopK:
49
+ """Forward pass for one sequence. Used for the unbatched path and for tests."""
50
+ return _topk_batch(model, [seq], k, device)[0]
51
+
52
+
53
+ def _topk_batch(model: Any, seqs: list[RenderedSeq], k: int, device: Any) -> list[SeqTopK]:
54
+ import torch
55
+
56
+ max_len = max(len(s.token_ids) for s in seqs)
57
+ ids = torch.zeros((len(seqs), max_len), dtype=torch.long)
58
+ att = torch.zeros((len(seqs), max_len), dtype=torch.long)
59
+ for i, s in enumerate(seqs):
60
+ ids[i, : len(s.token_ids)] = torch.tensor(s.token_ids)
61
+ att[i, : len(s.token_ids)] = 1
62
+ with torch.no_grad():
63
+ logits = model(input_ids=ids.to(device), attention_mask=att.to(device)).logits
64
+ out: list[SeqTopK] = []
65
+ for i, s in enumerate(seqs):
66
+ mask = np.array(s.score_mask[1:], bool) # position p scored if token p+1 is scored
67
+ pos = np.nonzero(mask)[0]
68
+ target = np.array(s.token_ids, np.int64)[pos + 1]
69
+ tops_i, tops_v, tgt_lp = [], [], []
70
+ for a in range(0, len(pos), POS_CHUNK):
71
+ sel = torch.from_numpy(pos[a : a + POS_CHUNK]).to(device)
72
+ lp = torch.log_softmax(logits[i, sel].float(), dim=-1)
73
+ v, ix = torch.topk(lp, k, dim=-1)
74
+ t = torch.from_numpy(target[a : a + POS_CHUNK]).to(device)
75
+ tgt_lp.append(lp.gather(1, t[:, None])[:, 0].cpu().numpy())
76
+ tops_i.append(ix.cpu().numpy())
77
+ tops_v.append(v.cpu().numpy())
78
+ if len(pos) == 0:
79
+ out.append(
80
+ SeqTopK(
81
+ s.seq_id, pos, target, np.zeros((0, k), np.int64), np.zeros((0, k)), np.zeros(0)
82
+ )
83
+ )
84
+ continue
85
+ out.append(
86
+ SeqTopK(
87
+ s.seq_id,
88
+ pos.astype(np.int32),
89
+ target,
90
+ np.concatenate(tops_i),
91
+ np.concatenate(tops_v).astype(np.float64),
92
+ np.concatenate(tgt_lp).astype(np.float64),
93
+ )
94
+ )
95
+ return out
96
+
97
+
98
+ def eos_ids(model: Any, tok: Any) -> set[int]:
99
+ """Every id that ends a generation: the tokenizer's EOS plus the model's generation_config."""
100
+ out: set[int] = set()
101
+ if tok.eos_token_id is not None:
102
+ out.add(int(tok.eos_token_id))
103
+ gc = getattr(getattr(model, "generation_config", None), "eos_token_id", None)
104
+ if isinstance(gc, int):
105
+ out.add(gc)
106
+ elif gc:
107
+ out.update(int(x) for x in gc)
108
+ return out
109
+
110
+
111
+ def greedy_generate(
112
+ model: Any, tok: Any, prompt_ids: list[int], max_new: int, device: Any
113
+ ) -> list[int]:
114
+ """Plain greedy decoding. Sampling settings from the model's generation_config (temperature,
115
+ top_p, top_k and above all repetition_penalty, which Qwen sets to 1.05 to 1.1) are switched
116
+ off on purpose: every backend must decode the same way or the comparison is unfair."""
117
+ import torch
118
+
119
+ inp = torch.tensor([prompt_ids], device=device)
120
+ with torch.no_grad():
121
+ out = model.generate(
122
+ input_ids=inp,
123
+ attention_mask=torch.ones_like(inp),
124
+ max_new_tokens=max_new,
125
+ do_sample=False,
126
+ temperature=None,
127
+ top_p=None,
128
+ top_k=None,
129
+ repetition_penalty=1.0,
130
+ pad_token_id=tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id,
131
+ )
132
+ new = out[0, len(prompt_ids) :].tolist()
133
+ stop = eos_ids(model, tok)
134
+ for i, t in enumerate(new):
135
+ if t in stop:
136
+ return new[:i]
137
+ return new
138
+
139
+
140
+ def _greedy(model: Any, tok: Any, g: RenderedGreedy, max_new: int, device: Any) -> list[int]:
141
+ return greedy_generate(model, tok, g.prompt_ids, max_new, device)
142
+
143
+
144
+ def build_pack_from_objects(
145
+ model: Any,
146
+ tok: Any,
147
+ out_dir: str | Path,
148
+ *,
149
+ repo: str,
150
+ revision: str | None = None,
151
+ dtype: str = "bfloat16",
152
+ probes_version: str = "v1",
153
+ probes_file: Path | None = None,
154
+ k: int = 32,
155
+ batch_size: int = 4,
156
+ max_new_tokens: int = 256,
157
+ logp_dtype: str = "float32",
158
+ license_: str | None = None,
159
+ device: Any = "cpu",
160
+ device_label: str = "cpu",
161
+ progress: Callable[[int, int], None] | None = None,
162
+ max_len: int = 512,
163
+ ) -> Manifest:
164
+ """Build a pack from an already loaded model and tokenizer."""
165
+ import torch
166
+ import transformers
167
+
168
+ probes = load_probes(probes_version, probes_file)
169
+ seqs, greedy = render_probes(probes, tok, max_len=max_len)
170
+ writer = PackWriter(out_dir, k=k, logp_dtype=logp_dtype)
171
+ writer.write_probes(seqs)
172
+ model.eval()
173
+
174
+ order = sorted(range(len(seqs)), key=lambda i: len(seqs[i].token_ids))
175
+ done = 0
176
+ for a in range(0, len(order), batch_size):
177
+ batch = [seqs[i] for i in order[a : a + batch_size]]
178
+ for r in _topk_batch(model, batch, k, device):
179
+ writer.add_seq(r)
180
+ done += len(batch)
181
+ if progress:
182
+ progress(done, len(seqs) + len(greedy))
183
+
184
+ rows = []
185
+ for g in greedy:
186
+ ref = _greedy(model, tok, g, max_new_tokens, device)
187
+ rows.append(
188
+ {
189
+ "seq_id": g.seq_id,
190
+ "group": g.group,
191
+ "lang": g.lang,
192
+ "prompt_ids": g.prompt_ids,
193
+ "ref_ids": ref,
194
+ "ref_text": tok.decode(ref, skip_special_tokens=True),
195
+ }
196
+ )
197
+ done += 1
198
+ if progress:
199
+ progress(done, len(seqs) + len(greedy))
200
+ writer.write_greedy(rows)
201
+
202
+ return writer.finalize(
203
+ model=ModelRef(repo=repo, revision=revision, dtype=dtype, license=license_),
204
+ tokenizer=tokenizer_info(tok),
205
+ probes_set=probes_version,
206
+ probes_sha256=_probes_hash(probes_version, probes_file),
207
+ n_sequences=len(seqs),
208
+ chat_template_sha256=chat_template_hash(tok),
209
+ greedy=GreedyInfo(n_prompts=len(greedy), max_new_tokens=max_new_tokens),
210
+ runner=RunnerInfo(
211
+ quantgap=__version__,
212
+ torch=torch.__version__,
213
+ transformers=transformers.__version__,
214
+ device=device_label,
215
+ ),
216
+ )
217
+
218
+
219
+ def _probes_hash(version: str, path: Path | None) -> str:
220
+ if path is None:
221
+ return probes_sha256(version)
222
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
223
+
224
+
225
+ def load_reference(
226
+ repo: str, revision: str | None, device: str = "auto"
227
+ ) -> tuple[Any, Any, Any, str, str]:
228
+ """Load the reference model. Returns (model, tokenizer, device, dtype_name, device_label)."""
229
+ import torch
230
+ from transformers import AutoModelForCausalLM, AutoTokenizer
231
+
232
+ has_cuda = torch.cuda.is_available()
233
+ use_cuda = device == "cuda" or (device == "auto" and has_cuda)
234
+ if use_cuda and not has_cuda:
235
+ raise RuntimeError("--device cuda was asked for, but no CUDA GPU was found.")
236
+ dtype = torch.bfloat16 if use_cuda else torch.float32
237
+ tok = AutoTokenizer.from_pretrained(repo, revision=revision)
238
+ # `torch_dtype` was renamed `dtype` in newer transformers; pass whichever this version knows.
239
+ import transformers
240
+
241
+ major_minor = tuple(int(x) for x in transformers.__version__.split(".")[:2])
242
+ dtype_key = "dtype" if major_minor >= (4, 56) else "torch_dtype"
243
+ kwargs: dict[str, Any] = {"revision": revision, dtype_key: dtype}
244
+ if use_cuda and torch.cuda.device_count() > 1:
245
+ kwargs["device_map"] = "auto" # spread across GPUs (Kaggle T4 x2), offload if needed
246
+ model = AutoModelForCausalLM.from_pretrained(repo, **kwargs)
247
+ if "device_map" in kwargs:
248
+ dev = next(model.parameters()).device
249
+ label = f"{torch.cuda.device_count()}x {torch.cuda.get_device_name(0)}"
250
+ elif use_cuda:
251
+ model = model.to("cuda")
252
+ dev = torch.device("cuda")
253
+ label = torch.cuda.get_device_name(0)
254
+ else:
255
+ dev = torch.device("cpu")
256
+ label = "cpu"
257
+ return model, tok, dev, str(dtype).replace("torch.", ""), label
258
+
259
+
260
+ def refresh_greedy(
261
+ pack_dir: str | Path,
262
+ model: Any,
263
+ tok: Any,
264
+ device: Any = "cpu",
265
+ progress: Callable[[int, int], None] | None = None,
266
+ ) -> Manifest:
267
+ """Regenerate only greedy.parquet of an existing pack (keeps the top-k shards).
268
+
269
+ The pack id changes because the manifest holds the hash of every file."""
270
+ import json
271
+
272
+ from quantgap.pack.reader import Pack
273
+ from quantgap.util import sha256_file
274
+
275
+ pack_dir = Path(pack_dir)
276
+ pack = Pack.load(pack_dir)
277
+ old = pack.greedy()
278
+ rows = []
279
+ for i, g in enumerate(old):
280
+ ref = greedy_generate(
281
+ model, tok, list(g.prompt_ids), pack.manifest.greedy.max_new_tokens, device
282
+ )
283
+ rows.append(
284
+ {
285
+ "seq_id": g.seq_id,
286
+ "group": g.group,
287
+ "lang": g.lang,
288
+ "prompt_ids": list(g.prompt_ids),
289
+ "ref_ids": ref,
290
+ "ref_text": tok.decode(ref, skip_special_tokens=True),
291
+ }
292
+ )
293
+ if progress:
294
+ progress(i + 1, len(old))
295
+ PackWriter(pack_dir, k=pack.manifest.k, logp_dtype=pack.manifest.logp_dtype).write_greedy(rows)
296
+ files = dict(pack.manifest.files)
297
+ files["greedy.parquet"] = sha256_file(pack_dir / "greedy.parquet")
298
+ manifest = pack.manifest.model_copy(update={"files": files})
299
+ text = json.dumps(manifest.model_dump(by_alias=True, mode="json"), indent=2, ensure_ascii=False)
300
+ (pack_dir / "manifest.json").write_text(text + "\n", encoding="utf-8")
301
+ return manifest
@@ -0,0 +1,162 @@
1
+ """Write a pack: parquet shards plus manifest.json, with a SHA-256 for every file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pyarrow as pa
11
+ import pyarrow.parquet as pq
12
+
13
+ from quantgap.models import (
14
+ GreedyInfo,
15
+ Manifest,
16
+ ModelRef,
17
+ ProbesInfo,
18
+ RunnerInfo,
19
+ TokenizerInfo,
20
+ )
21
+ from quantgap.pack.reader import SeqTopK
22
+ from quantgap.probes.render import RenderedSeq
23
+ from quantgap.util import sha256_file, utc_now
24
+
25
+ SHARD_ROWS = 40_000
26
+ _COMPRESSION = "zstd"
27
+
28
+
29
+ def _fixed_list(values: np.ndarray, width: int, dtype: pa.DataType) -> pa.Array:
30
+ n = len(values)
31
+ offsets = pa.array(np.arange(0, (n + 1) * width, width, dtype=np.int32))
32
+ return pa.ListArray.from_arrays(offsets, pa.array(values.reshape(-1), type=dtype))
33
+
34
+
35
+ def _ragged_list(rows: list[np.ndarray], dtype: pa.DataType) -> pa.Array:
36
+ lengths = np.array([len(r) for r in rows], dtype=np.int64)
37
+ offsets = pa.array(np.concatenate([[0], np.cumsum(lengths)]).astype(np.int32))
38
+ flat = np.concatenate(rows) if rows else np.array([])
39
+ return pa.ListArray.from_arrays(offsets, pa.array(flat, type=dtype))
40
+
41
+
42
+ class PackWriter:
43
+ def __init__(self, out_dir: str | Path, k: int, logp_dtype: str = "float32"):
44
+ self.out = Path(out_dir)
45
+ self.out.mkdir(parents=True, exist_ok=True)
46
+ self.k = k
47
+ self.logp_dtype = logp_dtype
48
+ self._np_dtype = np.float16 if logp_dtype == "float16" else np.float32
49
+ self._pa_dtype = pa.float16() if logp_dtype == "float16" else pa.float32()
50
+ self._buf: list[SeqTopK] = []
51
+ self._buf_rows = 0
52
+ self._shards: list[str] = []
53
+ self.n_positions = 0
54
+
55
+ # ---- top-k shards
56
+ def add_seq(self, s: SeqTopK) -> None:
57
+ if s.ids.shape != (len(s.pos), self.k):
58
+ raise ValueError(f"ids must have shape (n_positions, {self.k}), got {s.ids.shape}")
59
+ self._buf.append(s)
60
+ self._buf_rows += len(s.pos)
61
+ self.n_positions += len(s.pos)
62
+ if self._buf_rows >= SHARD_ROWS:
63
+ self._flush()
64
+
65
+ def _flush(self) -> None:
66
+ if not self._buf:
67
+ return
68
+ seq_id = np.concatenate([np.full(len(s.pos), s.seq_id, np.int32) for s in self._buf])
69
+ pos = np.concatenate([s.pos for s in self._buf]).astype(np.int32)
70
+ target = np.concatenate([s.target for s in self._buf]).astype(np.int32)
71
+ ids = np.concatenate([s.ids for s in self._buf]).astype(np.int32)
72
+ logp = np.concatenate([s.logp for s in self._buf]).astype(self._np_dtype)
73
+ lpt = np.concatenate([s.logp_target for s in self._buf]).astype(np.float32)
74
+ table = pa.table(
75
+ {
76
+ "seq_id": pa.array(seq_id, pa.int32()),
77
+ "pos": pa.array(pos, pa.int32()),
78
+ "target": pa.array(target, pa.int32()),
79
+ "ids": _fixed_list(ids, self.k, pa.int32()),
80
+ "logp": _fixed_list(logp, self.k, self._pa_dtype),
81
+ "logp_target": pa.array(lpt, pa.float32()),
82
+ }
83
+ )
84
+ name = f"topk-{len(self._shards):05d}.parquet"
85
+ pq.write_table(table, self.out / name, compression=_COMPRESSION)
86
+ self._shards.append(name)
87
+ self._buf, self._buf_rows = [], 0
88
+
89
+ # ---- probes and greedy
90
+ def write_probes(self, seqs: Iterable[RenderedSeq]) -> None:
91
+ seqs = list(seqs)
92
+ table = pa.table(
93
+ {
94
+ "seq_id": pa.array([s.seq_id for s in seqs], pa.int32()),
95
+ "group": pa.array([s.group for s in seqs], pa.string()),
96
+ "token_ids": _ragged_list(
97
+ [np.array(s.token_ids, np.int32) for s in seqs], pa.int32()
98
+ ),
99
+ "n_tokens": pa.array([len(s.token_ids) for s in seqs], pa.int32()),
100
+ "score_mask": _ragged_list(
101
+ [np.array(s.score_mask, bool) for s in seqs], pa.bool_()
102
+ ),
103
+ }
104
+ )
105
+ pq.write_table(table, self.out / "probes.parquet", compression=_COMPRESSION)
106
+
107
+ def write_greedy(self, rows: list[dict]) -> None:
108
+ """rows: dicts with seq_id, group, lang, prompt_ids, ref_ids, ref_text."""
109
+ table = pa.table(
110
+ {
111
+ "seq_id": pa.array([r["seq_id"] for r in rows], pa.int32()),
112
+ "group": pa.array([r["group"] for r in rows], pa.string()),
113
+ "lang": pa.array([r["lang"] for r in rows], pa.string()),
114
+ "prompt_ids": _ragged_list(
115
+ [np.array(r["prompt_ids"], np.int32) for r in rows], pa.int32()
116
+ ),
117
+ "ref_ids": _ragged_list(
118
+ [np.array(r["ref_ids"], np.int32) for r in rows], pa.int32()
119
+ ),
120
+ "ref_text": pa.array([r["ref_text"] for r in rows], pa.string()),
121
+ }
122
+ )
123
+ pq.write_table(table, self.out / "greedy.parquet", compression=_COMPRESSION)
124
+
125
+ # ---- manifest
126
+ def finalize(
127
+ self,
128
+ *,
129
+ model: ModelRef,
130
+ tokenizer: TokenizerInfo,
131
+ probes_set: str,
132
+ probes_sha256: str,
133
+ n_sequences: int,
134
+ chat_template_sha256: str | None,
135
+ greedy: GreedyInfo,
136
+ runner: RunnerInfo,
137
+ ) -> Manifest:
138
+ self._flush()
139
+ files = {
140
+ name: sha256_file(self.out / name)
141
+ for name in sorted([*self._shards, "probes.parquet", "greedy.parquet"])
142
+ }
143
+ m = Manifest(
144
+ model=model,
145
+ tokenizer=tokenizer,
146
+ probes=ProbesInfo(
147
+ set=probes_set,
148
+ sha256=probes_sha256,
149
+ n_sequences=n_sequences,
150
+ n_positions=self.n_positions,
151
+ ),
152
+ chat_template_sha256=chat_template_sha256,
153
+ k=self.k,
154
+ logp_dtype=self.logp_dtype,
155
+ greedy=greedy,
156
+ runner=runner,
157
+ files=files,
158
+ created_at=utc_now(),
159
+ )
160
+ text = json.dumps(m.model_dump(by_alias=True, mode="json"), indent=2, ensure_ascii=False)
161
+ (self.out / "manifest.json").write_text(text + "\n", encoding="utf-8")
162
+ return m
@@ -0,0 +1,11 @@
1
+ from quantgap.probes.registry import load_probes, probes_path, probes_sha256
2
+ from quantgap.probes.render import RenderedGreedy, RenderedSeq, render_probes
3
+
4
+ __all__ = [
5
+ "RenderedGreedy",
6
+ "RenderedSeq",
7
+ "load_probes",
8
+ "probes_path",
9
+ "probes_sha256",
10
+ "render_probes",
11
+ ]