Coverage for src/lilbee/catalog/models.py: 100%
86 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
1"""Catalog dataclasses and pydantic types. Imports only the catalog's leaf modules."""
3import re
4from dataclasses import dataclass
6from pydantic import BaseModel
8from lilbee.catalog.refs import quant_label
9from lilbee.catalog.types import ModelCompat, ModelTask
11# Minimum recommended floor so a tiny model still reports a sane RAM ask.
12_MIN_RAM_FLOOR_GB = 2.0
13# Working-set multiple over the on-disk size (weights + KV cache + overhead).
14_RAM_OVER_SIZE_FACTOR = 1.5
16_BYTES_PER_GB = 1024**3
18# Whole-file bytes per parameter for each llama.cpp quantization.
19#
20# Not the same quantity as ``gguf.GGML_QUANT_SIZES``, which gives the block size
21# of one ggml tensor type. llama.cpp never writes a homogeneous file: it promotes
22# ``output.weight`` and tied ``token_embd`` to Q6_K/Q8_0 whatever the ftype, and
23# leaves norms in F32, so a real file always costs more per weight than its
24# nominal type. These are measured file sizes; Qwen3-8B-GGUF publishes 0.614
25# (Q4_K_M), 0.699 (Q5_0), 0.714 (Q5_K_M), 0.821 (Q6_K) and 1.063 (Q8_0).
26#
27# No entry may sit below its base type's bytes per weight, which is physically
28# impossible; ``test_measured_quants_are_above_their_ggml_floor`` checks each one
29# against ``gguf.constants.GGML_QUANT_SIZES`` so a typo cannot survive review.
30_BYTES_PER_PARAM: dict[str, float] = {
31 "Q2_K": 0.33,
32 "Q3_K_S": 0.45,
33 "Q3_K_M": 0.488,
34 "Q3_K_L": 0.53,
35 "IQ4_XS": 0.532,
36 "Q4_0": 0.569,
37 "Q4_K_S": 0.575,
38 "Q4_K_M": 0.614,
39 "Q5_0": 0.699,
40 "Q5_K_S": 0.688,
41 "Q5_K_M": 0.714,
42 "Q6_K": 0.821,
43 "Q8_0": 1.063,
44 "F16": 2.0,
45 "BF16": 2.0,
46 "F32": 4.0,
47}
49# Q4_K_M heads the pull path's quant preference, so a label naming no bit width
50# at all estimates as if it were the quant a pull would most likely land on.
51_DEFAULT_BYTES_PER_PARAM = _BYTES_PER_PARAM["Q4_K_M"]
53# A quant the table does not name still says how many bits it packs. One fp16
54# scale per group costs an eighth on top, whatever the width, because a group is
55# sized to the width: 1-bit in groups of 128, 2-bit in 64, 4-bit in 32 all carry
56# two bytes per group. Reading the width beats falling back to Q4_K_M, which
57# reports a 2-bit file at more than twice its size.
58_SCALE_OVERHEAD = 1.125
59_BITS_PER_BYTE = 8
62def _packed_bits(quant: str) -> int | None:
63 """The bit width *quant* packs each weight into, or None if it names none."""
64 match = re.match(r"I?Q(\d)", quant)
65 return int(match.group(1)) if match else None
68def _quant_bytes_per_param(gguf_filename: str) -> float:
69 """Bytes per weight for the quant *gguf_filename* names."""
70 quant = quant_label(gguf_filename)
71 measured = _BYTES_PER_PARAM.get(quant)
72 if measured is not None:
73 return measured
74 bits = _packed_bits(quant)
75 if bits is None:
76 return _DEFAULT_BYTES_PER_PARAM
77 return bits / _BITS_PER_BYTE * _SCALE_OVERHEAD
80def estimate_min_ram_gb(size_gb: float) -> float:
81 """Estimate the RAM a model needs from its on-disk size (single source)."""
82 return round(max(_MIN_RAM_FLOOR_GB, size_gb * _RAM_OVER_SIZE_FACTOR), 1)
85def estimate_size_gb(params: int, gguf_filename: str) -> float:
86 """Estimate the on-disk GB of *gguf_filename* from a model's parameter count.
88 The HF listing API reports a parameter count (``gguf.total``) but no
89 per-file byte size; siblings carry no ``size`` on either the list or the
90 detail endpoint, and ``gguf.totalFileSize`` sums every quant in the repo
91 rather than the one file a pull fetches. Per-file bytes are only available
92 from ``/tree/main``, which is one extra request per repo and unaffordable
93 for a catalog page. Parameters times the quant's bytes-per-weight gets
94 within a few percent for a fraction of the cost.
95 """
96 if params <= 0:
97 return 0.0 # unknown: display as "?" in UI
98 return round(params * _quant_bytes_per_param(gguf_filename) / _BYTES_PER_GB, 1)
101class HfGgufMeta(BaseModel):
102 """GGUF metadata returned by the HF API when expand=gguf is requested.
104 ModelInfo.gguf is typed as ``dict | None`` upstream, so we validate it ourselves.
106 ``total`` is the model's parameter count, not a byte size; ``totalFileSize``
107 holds bytes. Verified against repos that name their own parameter count:
108 Qwen3-8B-GGUF reports ``total=8_190_000_000`` against 4.7 GB of files.
109 """
111 total: int = 0
112 architecture: str = ""
113 context_length: int = 0
116@dataclass
117class DownloadProgress:
118 """Human-readable snapshot of download progress.
120 ``percent`` is a float (0.0 to 100.0) so the ProgressBar renders smooth
121 fractional movement during multi-GB downloads. Call sites that need
122 an integer for display format it themselves.
123 """
125 percent: float
126 detail: str
127 is_cache_hit: bool
130@dataclass(frozen=True)
131class CatalogModel:
132 """One catalog entry, keyed by HuggingFace repo. ``gguf_filename`` may be a glob."""
134 hf_repo: str
135 gguf_filename: str
136 size_gb: float
137 min_ram_gb: float
138 description: str
139 featured: bool
140 downloads: int
141 task: ModelTask
142 architecture: str = ""
143 compat: ModelCompat = ModelCompat.UNKNOWN
144 # Parameter count. Size buckets key off this rather than on-disk bytes so a
145 # model keeps its bucket across quants. 0 when the repo publishes no GGUF
146 # metadata.
147 params: int = 0
149 @property
150 def ref(self) -> str:
151 """Browse-time ref (the HF repo); concrete filename is resolved at install."""
152 return self.hf_repo
154 @property
155 def display_name(self) -> str:
156 """Human-readable label derived from the HuggingFace repo id."""
157 # circular: models -> formatting via clean_display_name
158 from lilbee.catalog.formatting import clean_display_name
160 return clean_display_name(self.hf_repo)
163@dataclass(frozen=True)
164class CatalogResult:
165 """Paginated catalog result."""
167 total: int
168 limit: int
169 offset: int
170 models: list[CatalogModel]
171 has_more: bool = False
174@dataclass(frozen=True)
175class HfPage:
176 """One page of HuggingFace API results."""
178 models: list[CatalogModel]
179 has_more: bool
182@dataclass(frozen=True)
183class ModelVariant:
184 """One quantization within a model family. ``filename`` may be a glob."""
186 hf_repo: str
187 filename: str
188 param_count: str
189 quant: str
190 size_mb: int
191 mmproj_filename: str = ""
192 compat: ModelCompat = ModelCompat.UNKNOWN
195@dataclass(frozen=True)
196class ModelFamily:
197 """A group of related model variants (e.g. Qwen3 in multiple sizes)."""
199 slug: str # family slug for building refs: "qwen3"
200 name: str # display name: "Qwen3"
201 task: ModelTask
202 description: str
203 variants: tuple[ModelVariant, ...]