Files
Will Miao 19abf7ed03 feat(ui): show quantization badge on model cards
Distinguish same-version quantized models (e.g. GGUF Q4_K_M vs Q8_0) on
cards, which previously showed only the identical CivitAI version name.

Detection is content-based and stored as a three-state sidecar field
(absent = unchecked, "" = checked-unavailable, value = canonical name),
mirroring the autov3 pipeline:

- GGUF: minimal pure-struct header parser reads general.file_type
- safetensors: dominant tensor dtype F8_* -> FP8
- CivitAI files[].metadata.fp == "fp8" fills in as a fallback only
- A one-time backfill service covers existing libraries; each file
  costs only a few-hundred-byte header read

The footer renders the badge after the version name (suppressed in
grouped mode), gated by a new show_quantization_on_card setting that
follows the show_version_on_card toggle pattern.
2026-10-15 11:18:06 +08:00

208 lines
7.5 KiB
Python

"""Tests for py.utils.gguf_utils quantization detection."""
from __future__ import annotations
import io
import struct
from pathlib import Path
from typing import List, Tuple
import pytest
from py.utils.gguf_utils import detect_quantization, parse_gguf_quantization
def _string_kv(key: str, value: str) -> Tuple[bytes, int, bytes]:
raw = value.encode("utf-8")
return key.encode("utf-8"), 8, struct.pack("<Q", len(raw)) + raw
def _string_array_kv(key: str, values: List[str]) -> Tuple[bytes, int, bytes]:
raw = struct.pack("<I", 8) + struct.pack("<Q", len(values))
for value in values:
encoded = value.encode("utf-8")
raw += struct.pack("<Q", len(encoded)) + encoded
return key.encode("utf-8"), 9, raw
def _uint32_kv(key: str, value: int) -> Tuple[bytes, int, bytes]:
return key.encode("utf-8"), 4, struct.pack("<I", value)
def _write_gguf(
path: Path,
file_type: int,
*,
version: int = 3,
extra_kvs: List[Tuple[bytes, int, bytes]] | None = None,
) -> Path:
"""Write a minimal in-memory-built GGUF file with the given file_type."""
kvs = list(extra_kvs or []) + [_uint32_kv("general.file_type", file_type)]
blob = io.BytesIO()
blob.write(b"GGUF")
blob.write(struct.pack("<I", version))
blob.write(struct.pack("<Q", 0)) # tensor_count
blob.write(struct.pack("<Q", len(kvs)))
for key, value_type, raw in kvs:
blob.write(struct.pack("<Q", len(key)))
blob.write(key)
blob.write(struct.pack("<I", value_type))
blob.write(raw)
path.write_bytes(blob.getvalue())
return path
class TestGgufParser:
@pytest.mark.parametrize(
"file_type,expected",
[
(2, "Q4_0"),
(3, "Q4_1"),
(7, "Q8_0"),
(8, "Q5_0"),
(9, "Q5_1"),
(10, "Q2_K"),
(11, "Q3_K_S"),
(12, "Q3_K_M"),
(13, "Q3_K_L"),
(14, "Q4_K_S"),
(15, "Q4_K_M"),
(16, "Q5_K_S"),
(17, "Q5_K_M"),
(18, "Q6_K"),
(19, "IQ2_XXS"),
(20, "IQ2_XS"),
(21, "Q2_K_S"),
(25, "IQ4_NL"),
(30, "IQ4_XS"),
(36, "TQ1_0"),
(37, "TQ2_0"),
(39, "MXFP4"),
],
)
def test_quantized_file_types_map_to_canonical_names(
self, tmp_path: Path, file_type: int, expected: str
) -> None:
path = _write_gguf(tmp_path / "model.gguf", file_type)
assert parse_gguf_quantization(str(path)) == expected
@pytest.mark.parametrize("file_type", [0, 1, 32]) # F32, F16, BF16
def test_full_precision_file_types_return_none(
self, tmp_path: Path, file_type: int
) -> None:
path = _write_gguf(tmp_path / "model.gguf", file_type)
assert parse_gguf_quantization(str(path)) is None
def test_unknown_file_type_returns_none(self, tmp_path: Path) -> None:
path = _write_gguf(tmp_path / "model.gguf", 999)
assert parse_gguf_quantization(str(path)) is None
def test_file_type_after_string_and_array_values(self, tmp_path: Path) -> None:
"""Values of other metadata entries must be skipped correctly."""
path = _write_gguf(
tmp_path / "model.gguf",
15,
extra_kvs=[
_string_kv("general.architecture", "flux"),
_string_array_kv("tokenizer.ggml.tokens", ["a", "bb", "ccc"]),
_string_kv("tokenizer.chat_template", "x" * 5000),
],
)
assert parse_gguf_quantization(str(path)) == "Q4_K_M"
def test_bad_magic_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "model.gguf"
path.write_bytes(b"NOPE" + b"\x00" * 100)
assert parse_gguf_quantization(str(path)) is None
def test_unsupported_version_returns_none(self, tmp_path: Path) -> None:
path = _write_gguf(tmp_path / "model.gguf", 15, version=99)
assert parse_gguf_quantization(str(path)) is None
def test_truncated_after_magic_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "model.gguf"
path.write_bytes(b"GGUF")
assert parse_gguf_quantization(str(path)) is None
def test_truncated_mid_kv_returns_none(self, tmp_path: Path) -> None:
full = _write_gguf(tmp_path / "full.gguf", 15).read_bytes()
path = tmp_path / "model.gguf"
path.write_bytes(full[:20])
assert parse_gguf_quantization(str(path)) is None
def test_absurd_kv_count_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "model.gguf"
path.write_bytes(
b"GGUF" + struct.pack("<I", 3) + struct.pack("<Q", 0) + struct.pack("<Q", 10**9)
)
assert parse_gguf_quantization(str(path)) is None
def test_missing_file_returns_none(self, tmp_path: Path) -> None:
assert parse_gguf_quantization(str(tmp_path / "nope.gguf")) is None
class TestSafetensorsDetection:
def test_fp8_dominant_dtype_reports_fp8(self, tmp_path: Path) -> None:
torch = pytest.importorskip("torch")
from safetensors.torch import save_file
path = tmp_path / "fp8.safetensors"
save_file(
{"weight": torch.zeros(64, 64, dtype=torch.float8_e4m3fn)},
str(path),
)
assert detect_quantization(str(path)) == "FP8"
def test_fp16_returns_none(self, tmp_path: Path) -> None:
torch = pytest.importorskip("torch")
from safetensors.torch import save_file
path = tmp_path / "fp16.safetensors"
save_file({"weight": torch.zeros(64, 64, dtype=torch.float16)}, str(path))
assert detect_quantization(str(path)) is None
def test_dominance_is_by_bytes_not_tensor_count(self, tmp_path: Path) -> None:
"""Many tiny fp8 tensors must not flip a large fp16 model to FP8."""
torch = pytest.importorskip("torch")
from safetensors.torch import save_file
path = tmp_path / "mixed.safetensors"
tensors = {"big": torch.zeros(1024, 1024, dtype=torch.float16)}
for i in range(10):
tensors[f"tiny_fp8_{i}"] = torch.zeros(4, dtype=torch.float8_e4m3fn)
save_file(tensors, str(path))
assert detect_quantization(str(path)) is None
def test_large_fp8_with_small_fp32_side_tensors_reports_fp8(self, tmp_path: Path) -> None:
torch = pytest.importorskip("torch")
from safetensors.torch import save_file
path = tmp_path / "mixed_fp8.safetensors"
save_file(
{
"weight": torch.zeros(1024, 1024, dtype=torch.float8_e4m3fn),
"bias": torch.zeros(16, dtype=torch.float32),
},
str(path),
)
assert detect_quantization(str(path)) == "FP8"
def test_invalid_safetensors_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "broken.safetensors"
path.write_bytes(b"not a safetensors file")
assert detect_quantization(str(path)) is None
class TestDetectQuantizationDispatch:
def test_gguf_extension_dispatches_to_gguf_parser(self, tmp_path: Path) -> None:
path = _write_gguf(tmp_path / "model.GGUF", 15)
assert detect_quantization(str(path)) == "Q4_K_M"
def test_unknown_extension_returns_none(self, tmp_path: Path) -> None:
path = tmp_path / "model.txt"
path.write_text("hello", encoding="utf-8")
assert detect_quantization(str(path)) is None
def test_missing_file_returns_none(self, tmp_path: Path) -> None:
assert detect_quantization(str(tmp_path / "missing.safetensors")) is None