ggufkit 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.
ggufkit/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """ggufkit: fingerprint, verify and visualize GGUF models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("ggufkit")
9
+ except PackageNotFoundError: # pragma: no cover - only when running from a bare checkout
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["__version__"]
ggufkit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m ggufkit``: the same as the ``ggufkit`` command."""
2
+
3
+ from ggufkit.cli import entry
4
+
5
+ entry()
ggufkit/_util.py ADDED
@@ -0,0 +1,75 @@
1
+ """Small helpers shared by the commands: safe output files and argument checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+
16
+ def prepare_output(path: Path, *, force: bool) -> None:
17
+ """Fail early, before any expensive work, if ``path`` cannot or must not be written."""
18
+ if path.exists() and not force:
19
+ msg = f"{path} already exists (use --force to overwrite)"
20
+ raise FileExistsError(msg)
21
+ parent = path.parent if str(path.parent) else Path()
22
+ if not parent.is_dir():
23
+ msg = f"{parent} is not a directory"
24
+ raise FileNotFoundError(msg)
25
+ if not os.access(parent, os.W_OK):
26
+ msg = f"cannot write to {parent}"
27
+ raise PermissionError(msg)
28
+
29
+
30
+ @contextmanager
31
+ def atomic_output(path: Path) -> Iterator[Path]:
32
+ """Yield a temporary path next to ``path``; move it into place only if the block succeeds.
33
+
34
+ An interrupted or failed write leaves ``path`` untouched instead of half-written.
35
+ """
36
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
37
+ try:
38
+ yield tmp
39
+ tmp.replace(path)
40
+ finally:
41
+ tmp.unlink(missing_ok=True)
42
+
43
+
44
+ def write_text_atomic(path: Path, text: str) -> None:
45
+ with atomic_output(path) as tmp:
46
+ tmp.write_text(text, encoding="utf-8")
47
+
48
+
49
+ def positive_int(value: str) -> int:
50
+ n = int(value)
51
+ if n < 1:
52
+ msg = f"must be at least 1, got {value}"
53
+ raise argparse.ArgumentTypeError(msg)
54
+ return n
55
+
56
+
57
+ def non_negative_int(value: str) -> int:
58
+ n = int(value)
59
+ if n < 0:
60
+ msg = f"must not be negative, got {value}"
61
+ raise argparse.ArgumentTypeError(msg)
62
+ return n
63
+
64
+
65
+ def positive_float(value: str) -> float:
66
+ x = float(value)
67
+ if not x > 0:
68
+ msg = f"must be greater than 0, got {value}"
69
+ raise argparse.ArgumentTypeError(msg)
70
+ return x
71
+
72
+
73
+ def to_stderr(message: str) -> None:
74
+ """Progress and warnings go to stderr, so stdout carries only results."""
75
+ print(message, file=sys.stderr)
ggufkit/canon.py ADDED
@@ -0,0 +1,200 @@
1
+ """Canonical byte encodings behind the ``ggufkit-fp/1`` fingerprint specification.
2
+
3
+ Everything here is deliberately boring: fixed-width little-endian integers, SHA-256, and no
4
+ dependence on where in the file a value happened to be stored. The full specification lives in
5
+ ``docs/FINGERPRINT.md``; this module is its reference implementation.
6
+
7
+ Why not just hash the file? Because two GGUF files with identical weights routinely differ in
8
+ bytes: metadata gets reordered or edited by ``gguf-set-metadata``, a model gets split into
9
+ shards, alignment padding changes. A content hash that ignores all of that lets you answer
10
+ "is this the same model?" and "did only the metadata change?" without re-downloading anything.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import struct
16
+ import sys
17
+ from hashlib import sha256
18
+ from typing import TYPE_CHECKING
19
+
20
+ import numpy as np
21
+ from gguf import GGUFEndian, GGUFValueType
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterable
25
+
26
+ from gguf.gguf_reader import ReaderField, ReaderTensor
27
+
28
+ SPEC_ID = "ggufkit-fp/1"
29
+
30
+ #: Tensor data is hashed in slices of this many bytes so multi-gigabyte tensors never get copied.
31
+ CHUNK_SIZE = 64 << 20
32
+
33
+ _KV_TAG = b"kv\0"
34
+ _TI_TAG = b"ti\0"
35
+ _WT_TAG = b"wt\0"
36
+
37
+ _SCALAR_TYPES = frozenset(
38
+ {
39
+ GGUFValueType.UINT8,
40
+ GGUFValueType.INT8,
41
+ GGUFValueType.UINT16,
42
+ GGUFValueType.INT16,
43
+ GGUFValueType.UINT32,
44
+ GGUFValueType.INT32,
45
+ GGUFValueType.FLOAT32,
46
+ GGUFValueType.UINT64,
47
+ GGUFValueType.INT64,
48
+ GGUFValueType.FLOAT64,
49
+ GGUFValueType.BOOL,
50
+ }
51
+ )
52
+
53
+
54
+ class UnsupportedModelError(ValueError):
55
+ """Raised for files whose canonical form ggufkit cannot compute yet."""
56
+
57
+
58
+ def _u32(value: int) -> bytes:
59
+ return struct.pack("<I", value)
60
+
61
+
62
+ def _u64(value: int) -> bytes:
63
+ return struct.pack("<Q", value)
64
+
65
+
66
+ def _little_endian_bytes(arr: np.ndarray) -> bytes:
67
+ """Raw bytes of ``arr`` with multi-byte values in little-endian order."""
68
+ if arr.dtype.byteorder == ">" or (arr.dtype.byteorder == "=" and sys.byteorder == "big"):
69
+ arr = arr.astype(arr.dtype.newbyteorder("<"))
70
+ return arr.tobytes()
71
+
72
+
73
+ def _encode_value(parts: list[np.ndarray], cursor: int, vtype: GGUFValueType) -> tuple[bytes, int]:
74
+ """Encode the value starting at ``parts[cursor]`` and return it with the next cursor.
75
+
76
+ The reader stores a field's parts in file order, so walking them with a cursor rebuilds
77
+ the exact structure of the value, including nested arrays which the reader otherwise
78
+ flattens.
79
+ """
80
+ if vtype == GGUFValueType.STRING:
81
+ data = parts[cursor + 1]
82
+ return _u64(int(data.nbytes)) + data.tobytes(), cursor + 2
83
+ if vtype in _SCALAR_TYPES:
84
+ arr = parts[cursor]
85
+ if vtype == GGUFValueType.BOOL:
86
+ return struct.pack("<B", 1 if bool(arr[0]) else 0), cursor + 1
87
+ return _little_endian_bytes(arr), cursor + 1
88
+ if vtype == GGUFValueType.ARRAY:
89
+ item_type = GGUFValueType(int(parts[cursor][0]))
90
+ count = int(parts[cursor + 1][0])
91
+ cursor += 2
92
+ out = bytearray(_u32(int(item_type)) + _u64(count))
93
+ for _ in range(count):
94
+ chunk, cursor = _encode_value(parts, cursor, item_type)
95
+ out += chunk
96
+ return bytes(out), cursor
97
+ msg = f"Unknown GGUF value type {vtype!r}"
98
+ raise UnsupportedModelError(msg)
99
+
100
+
101
+ def encode_kv(field: ReaderField) -> bytes:
102
+ """Canonical encoding of one metadata entry: tag, name, type, value."""
103
+ if len(field.parts) < 3 or not field.types:
104
+ msg = f"Field {field.name!r} is not a metadata entry"
105
+ raise ValueError(msg)
106
+ name = field.name.encode("utf-8")
107
+ vtype = field.types[0]
108
+ payload, end = _encode_value(field.parts, 3, vtype)
109
+ if end != len(field.parts):
110
+ msg = f"Field {field.name!r}: consumed {end} of {len(field.parts)} parts"
111
+ raise UnsupportedModelError(msg)
112
+ return _KV_TAG + _u64(len(name)) + name + _u32(int(vtype)) + payload
113
+
114
+
115
+ def encode_tensor_info(tensor: ReaderTensor) -> bytes:
116
+ """Canonical encoding of a tensor's descriptor: tag, name, dims in GGUF order, type.
117
+
118
+ The file offset is left out on purpose: it changes whenever anything before the tensor
119
+ changes, and it says nothing about the tensor.
120
+ """
121
+ name = tensor.name.encode("utf-8")
122
+ dims = [int(d) for d in tensor.shape]
123
+ return (
124
+ _TI_TAG
125
+ + _u64(len(name))
126
+ + name
127
+ + _u32(len(dims))
128
+ + b"".join(_u64(d) for d in dims)
129
+ + _u32(int(tensor.tensor_type))
130
+ )
131
+
132
+
133
+ def kv_digest(field: ReaderField) -> bytes:
134
+ return sha256(encode_kv(field)).digest()
135
+
136
+
137
+ def tensor_info_digest(tensor: ReaderTensor) -> bytes:
138
+ return sha256(encode_tensor_info(tensor)).digest()
139
+
140
+
141
+ def is_element_type(tensor: ReaderTensor) -> bool:
142
+ """Return whether the reader exposes the tensor with a real dtype (F32, F16, F64, I8..I64)."""
143
+ return tensor.data.dtype != np.uint8
144
+
145
+
146
+ def tensor_data_digest(
147
+ tensor: ReaderTensor,
148
+ file_endianess: GGUFEndian,
149
+ chunk_size: int = CHUNK_SIZE,
150
+ ) -> bytes:
151
+ """SHA-256 of the tensor's stored bytes, normalized to little-endian for element types.
152
+
153
+ Quantized blocks mix scales and packed values, so their byte order cannot be normalized
154
+ without decoding the block layout. Big-endian quantized files are refused rather than
155
+ silently fingerprinted differently; convert them with llama.cpp's ``gguf-convert-endian``.
156
+ """
157
+ data = tensor.data
158
+ if is_element_type(tensor):
159
+ if data.dtype.byteorder == ">" or (data.dtype.byteorder == "=" and sys.byteorder == "big"):
160
+ data = data.astype(data.dtype.newbyteorder("<"))
161
+ elif file_endianess == GGUFEndian.BIG:
162
+ msg = (
163
+ f"Tensor {tensor.name!r} is {tensor.tensor_type.name} in a big-endian file; "
164
+ "convert the file to little-endian first (gguf-convert-endian)"
165
+ )
166
+ raise UnsupportedModelError(msg)
167
+
168
+ flat = np.ascontiguousarray(data).reshape(-1).view(np.uint8)
169
+ digest = sha256()
170
+ for start in range(0, flat.size, chunk_size):
171
+ digest.update(flat[start : start + chunk_size])
172
+ return digest.digest()
173
+
174
+
175
+ def encode_weight(name: str, data_digest: bytes) -> bytes:
176
+ """Canonical encoding of one tensor's data under its name: tag, name, data digest.
177
+
178
+ Binding the name to the bytes is what makes swapping the data of two tensors with the
179
+ same shape and type show up in ``weights``.
180
+ """
181
+ raw = name.encode("utf-8")
182
+ return _WT_TAG + _u64(len(raw)) + raw + data_digest
183
+
184
+
185
+ def weight_digest(name: str, data_digest: bytes) -> bytes:
186
+ return sha256(encode_weight(name, data_digest)).digest()
187
+
188
+
189
+ def combine(digests: Iterable[bytes]) -> bytes:
190
+ """Hash a sequence of digests in the order given."""
191
+ return sha256(b"".join(digests)).digest()
192
+
193
+
194
+ def overall_digest(kv: bytes, tensor_info: bytes, weights: bytes) -> bytes:
195
+ return sha256(SPEC_ID.encode("ascii") + b"\0" + kv + tensor_info + weights).digest()
196
+
197
+
198
+ def sorted_by_name(names: Iterable[str]) -> list[str]:
199
+ """Names in byte-wise UTF-8 order, which is what the spec requires."""
200
+ return sorted(names, key=lambda s: s.encode("utf-8"))
ggufkit/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line entry point: ``ggufkit <command> ...``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import re
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ggufkit import __version__
12
+ from ggufkit import diff as diff_cmd
13
+ from ggufkit import fingerprint as fingerprint_cmd
14
+ from ggufkit import manifest as manifest_cmd
15
+ from ggufkit import stitch as stitch_cmd
16
+ from ggufkit import verify as verify_cmd
17
+ from ggufkit import viz as viz_cmd
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Sequence
21
+
22
+ EXIT_OK = 0
23
+ EXIT_MISMATCH = 1
24
+ EXIT_ERROR = 2
25
+ EXIT_BROKEN_PIPE = 141
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="ggufkit",
31
+ description="Fingerprint, verify and visualize GGUF models.",
32
+ )
33
+ parser.add_argument("--version", action="version", version=f"ggufkit {__version__}")
34
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
35
+ fingerprint_cmd.add_parser(sub)
36
+ manifest_cmd.add_parser(sub)
37
+ verify_cmd.add_parser(sub)
38
+ diff_cmd.add_parser(sub)
39
+ stitch_cmd.add_parser(sub)
40
+ viz_cmd.add_parser(sub)
41
+ return parser
42
+
43
+
44
+ def main(argv: Sequence[str] | None = None) -> int:
45
+ parser = build_parser()
46
+ args = parser.parse_args(argv)
47
+ func = getattr(args, "func", None)
48
+ if func is None:
49
+ parser.print_help()
50
+ return EXIT_ERROR
51
+ try:
52
+ return int(func(args))
53
+ except BrokenPipeError:
54
+ # The reader went away (``ggufkit ... | head``): stop quietly, like other Unix tools.
55
+ return EXIT_BROKEN_PIPE
56
+ except (ValueError, OSError, re.error) as e:
57
+ # ValueError covers ggufkit's own errors (bad models, manifests, arguments).
58
+ print(f"error: {e}", file=sys.stderr)
59
+ return EXIT_ERROR
60
+ except KeyboardInterrupt:
61
+ print("interrupted", file=sys.stderr)
62
+ return 130
63
+
64
+
65
+ def entry() -> None: # pragma: no cover - exercised by the installed ``ggufkit`` script
66
+ """Console-script entry point: :func:`main`, plus a quiet exit when stdout is closed."""
67
+ try:
68
+ code = main()
69
+ sys.stdout.flush()
70
+ except BrokenPipeError:
71
+ code = EXIT_BROKEN_PIPE
72
+ if code == EXIT_BROKEN_PIPE:
73
+ # Point stdout at /dev/null so the interpreter's final flush cannot fail again.
74
+ devnull = os.open(os.devnull, os.O_WRONLY)
75
+ os.dup2(devnull, sys.stdout.fileno())
76
+ sys.exit(code)
77
+
78
+
79
+ if __name__ == "__main__": # pragma: no cover
80
+ entry()
ggufkit/dequant.py ADDED
@@ -0,0 +1,70 @@
1
+ """Turn any tensor ggufkit can read into float32, reusing llama.cpp's own dequantizers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cache
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+ from gguf import GGMLQuantizationType, GGUFEndian, quants
10
+ from gguf.constants import GGML_QUANT_SIZES
11
+
12
+ from ggufkit.canon import UnsupportedModelError, is_element_type
13
+
14
+ if TYPE_CHECKING:
15
+ from gguf.gguf_reader import ReaderTensor
16
+
17
+ ELEMENT_TYPES = frozenset(
18
+ {
19
+ GGMLQuantizationType.F32,
20
+ GGMLQuantizationType.F16,
21
+ GGMLQuantizationType.F64,
22
+ GGMLQuantizationType.I8,
23
+ GGMLQuantizationType.I16,
24
+ GGMLQuantizationType.I32,
25
+ GGMLQuantizationType.I64,
26
+ }
27
+ )
28
+
29
+
30
+ @cache
31
+ def can_dequantize(qtype: GGMLQuantizationType) -> bool:
32
+ """Whether the installed ``gguf`` package knows how to decode ``qtype``."""
33
+ if qtype in ELEMENT_TYPES:
34
+ return True
35
+ if qtype not in GGML_QUANT_SIZES:
36
+ return False
37
+ _block, type_size = GGML_QUANT_SIZES[qtype]
38
+ try:
39
+ quants.dequantize(np.zeros((1, type_size), dtype=np.uint8), qtype)
40
+ except NotImplementedError:
41
+ return False
42
+ except Exception: # noqa: BLE001 - zero blocks are not always valid, but the path exists
43
+ return True
44
+ return True
45
+
46
+
47
+ def to_f32(
48
+ tensor: ReaderTensor,
49
+ file_endianess: GGUFEndian = GGUFEndian.LITTLE,
50
+ rows: slice | None = None,
51
+ ) -> np.ndarray:
52
+ """Decode ``tensor`` (or a slice of its leading axis) to a float32 array.
53
+
54
+ The result has numpy shape, which is the reverse of the GGUF ``ne`` order: a
55
+ ``[n_embd, n_vocab]`` GGUF tensor comes back as ``(n_vocab, n_embd)``. Slicing by rows
56
+ keeps memory bounded on multi-gigabyte tensors: quantization blocks never span rows.
57
+ """
58
+ data = tensor.data if rows is None else tensor.data[rows]
59
+ if is_element_type(tensor):
60
+ return data.astype(np.float32, copy=False)
61
+ if file_endianess == GGUFEndian.BIG:
62
+ msg = (
63
+ f"Tensor {tensor.name!r} is {tensor.tensor_type.name} in a big-endian file; "
64
+ "convert the file to little-endian first (gguf-convert-endian)"
65
+ )
66
+ raise UnsupportedModelError(msg)
67
+ if not can_dequantize(tensor.tensor_type):
68
+ msg = f"The installed gguf package cannot dequantize {tensor.tensor_type.name}"
69
+ raise UnsupportedModelError(msg)
70
+ return quants.dequantize(data, tensor.tensor_type)