File size: 9,963 Bytes
046ace0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e706366
046ace0
 
 
 
 
 
 
 
 
 
 
 
e706366
f4b69d0
79f188d
046ace0
 
 
 
 
 
 
 
 
9ced3b9
046ace0
 
 
 
9ced3b9
046ace0
 
79f188d
 
046ace0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e706366
 
 
 
 
 
 
 
 
 
 
 
046ace0
 
 
 
 
 
 
 
 
 
e706366
 
046ace0
9ced3b9
046ace0
 
 
 
 
 
79f188d
9ced3b9
44d8dfe
 
 
 
046ace0
e706366
 
9ced3b9
046ace0
f4b69d0
046ace0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b69d0
046ace0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""Ranking: for each model, the best quant for your hardware, then the models themselves.

Every quant of every repo holding the model is fitted to the hardware; the best is the
one that runs most comfortably (all on GPU, then experts in RAM, then more layers on
GPU, then CPU), and among equally comfortable ones the one that matches the priority.
Models are then ranked on that fit, popularity, recency and community reports.

Nothing here looks at who uploaded a quant beyond its download count.
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from datetime import datetime, timezone

from .catalogue import Group
from .detail import ModelDetail
from .fit import Fit, fit, llama_cpp_command, ollama_command
from .geometry import estimate_without_header
from .hardware import Hardware
from .parsing import QUANT_BPW, QuantFile, min_model_bytes, quant_bits

PRIORITIES = ["balanced", "quality", "small"]
SORTS = ["best", "popular", "recent", "community"]
WEIGHTS = {"fit": 0.35, "capacity": 0.20, "popularity": 0.25, "recency": 0.10, "community": 0.10}


@dataclass
class Query:
    hardware: Hardware | None
    context: int = 8192
    kv_type: str = "f16"
    priority: str = "balanced"
    min_bits: int = 0  # skip quants below this bit level (Q4 and up: 4)
    vram_only: bool = False  # discrete GPUs: nothing may spill into system RAM
    mmproj: str = "gpu"      # vision projector: "gpu" (llama-server default), "ram" or "off"


@dataclass
class Option:
    repo: str
    repo_downloads: int
    quant: QuantFile
    fit: Fit | None
    estimated: bool
    projector_bytes: int | None = None

    def to_dict(self, q: Query) -> dict:
        d = {"repo": self.repo, "repo_downloads": self.repo_downloads, **self.quant.to_dict(),
             "estimated": self.estimated, "fit": self.fit.to_dict() if self.fit else None,
             "projector_size": self.projector_bytes,
             "url": f"https://huggingface.co/{self.repo}"}
        if self.fit:
            proj = q.mmproj if self.projector_bytes and q.mmproj != "gpu" else None
            d["commands"] = {"llama.cpp": llama_cpp_command(self.repo, self.quant.quant, self.fit, q.context, q.kv_type, proj),
                             "ollama": ollama_command(self.repo, self.quant.quant)}
        else:
            d["commands"] = {"llama.cpp": f"llama-server -hf {self.repo}:{self.quant.quant} -c {q.context}",
                             "ollama": ollama_command(self.repo, self.quant.quant)}
        return d


def preference(opt: Option, priority: str) -> float:
    b = opt.quant.bpw or 4.85
    if priority == "quality":
        return min(b, 8.5)
    if priority == "small":
        return -(opt.quant.size or 0) / 1e9
    return -abs(b - 5.0) if b >= 3.5 else -3.0 - (3.5 - b)  # Q4_K_M .. Q5_K_M


def below_floor(qf: QuantFile, params: int | None, min_bits: int) -> bool:
    if not min_bits:
        return False
    bits = qf.bits
    if bits is None and params and qf.size:
        bits = quant_bits("", qf.size * 8 / params)
    return bits is not None and bits < min_bits


PLACEHOLDER = {0: "Q4_K_M", 1: "Q4_K_M", 2: "Q4_K_M", 3: "Q4_K_M", 4: "Q4_K_M", 5: "Q5_K_M", 6: "Q6_K", 8: "Q8_0"}


def options_for(group: Group, detail: ModelDetail | None, q: Query) -> list[Option]:
    opts: list[Option] = []
    geo = detail.geometry if detail else None
    n_vocab = detail.n_vocab if detail else None
    downloads = {r.id: r.downloads for r in group.repos}
    if detail:
        for repo, rf in detail.repos.items():
            for qf in rf.quants:
                if not qf.size or qf.size < min_model_bytes(group.params):
                    continue  # a draft model, head or encoder that survived the name checks
                if below_floor(qf, group.params, q.min_bits):
                    continue
                if geo is not None:
                    g = geo.scaled(qf.size, group.params)
                    est = False
                elif group.params:
                    g = estimate_without_header(group.params, qf.bpw or 4.85, qf.size, group.context_length, group.moe)
                    est = True
                else:
                    continue
                f = fit(g, q.hardware, q.context, q.kv_type, n_vocab, rf.mmproj_bytes or 0, q.vram_only, q.mmproj) if q.hardware else None
                opts.append(Option(repo, downloads.get(repo, 0), qf, f, est, rf.mmproj_bytes))
    # A size guessed from the parameter count, only while the files are unknown. Once they are
    # listed and none is a usable quant (a drafter, a projector), there is nothing to guess about.
    files_known = bool(detail and detail.repos and all(rf.error is None for rf in detail.repos.values()))
    if not opts and group.params and group.repos and not files_known:
        lead = group.repos[0]
        name = PLACEHOLDER.get(q.min_bits, "Q8_0")
        qf = QuantFile(name, [], int(group.params * QUANT_BPW[name] / 8), QUANT_BPW[name])
        g = (geo.scaled(qf.size, group.params) if geo else
             estimate_without_header(group.params, qf.bpw, qf.size, group.context_length, group.moe))
        f = fit(g, q.hardware, q.context, q.kv_type, n_vocab, vram_only=q.vram_only) if q.hardware else None
        opts.append(Option(lead.id, lead.downloads, qf, f, geo is None))
    return opts


def best_option(opts: list[Option], priority: str) -> Option | None:
    if not opts:
        return None
    return max(opts, key=lambda o: ((o.fit.score if o.fit else 0.0), preference(o, priority), o.repo_downloads))


@dataclass
class Scored:
    group: Group
    best: Option | None
    options: list[Option]
    score: float
    components: dict
    reasons: list[str] = field(default_factory=list)
    detail_loaded: bool = False
    detail_repos: dict | None = None

    def to_dict(self, q: Query, with_options: bool = False) -> dict:
        d = {**self.group.summary(), "score": round(self.score, 1), "components": self.components,
             "reasons": self.reasons, "detail_loaded": self.detail_loaded,
             "best": self.best.to_dict(q) if self.best else None}
        if with_options:
            d["options"] = [o.to_dict(q) for o in self.options]
        return d


def _age_days(ts: str | None) -> float | None:
    if not ts:
        return None
    try:
        t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
        return max((datetime.now(timezone.utc) - t).total_seconds() / 86400, 0)
    except ValueError:
        return None


class Engine:
    version = "fit-v1"

    @staticmethod
    def _capacity(g: Group, best: Option | None, q: Query) -> float:
        """Bigger models that still run well rank higher: a 27B that fits beats a 1B that fits.
        Scaled to what the hardware could hold at ~4.5 bits; neutral without hardware."""
        if q.hardware is None or not g.params or best is None or best.fit is None or best.fit.tier in ("no", "no_vram"):
            return 0.5 if q.hardware is None else 0.0
        mem = q.hardware.usable_vram_gb() if q.hardware.kind != "cpu" else q.hardware.ram_gb * 0.8
        if q.hardware.kind == "gpu":
            mem += q.hardware.ram_gb * 0.25  # experts or layers can spill into RAM
        reference = max(mem * 1.6e9, 1e9)   # params that fit in `mem` at ~5 bits
        return round(min(math.log1p(g.params / 1e9) / math.log1p(reference / 1e9), 1.0), 3)

    def score(self, groups: list[Group], q: Query, details: dict[str, ModelDetail], community: dict) -> list[Scored]:
        max_dl = max((g.downloads for g in groups), default=1) or 1
        out = []
        for g in groups:
            det = details.get(g.key)
            opts = options_for(g, det, q)
            best = best_option(opts, q.priority)
            comp, reasons = {}, []
            if q.hardware is None or best is None or best.fit is None:
                comp["fit"] = 0.5
                if q.hardware is None:
                    reasons.append("Pick your hardware to see what fits.")
            else:
                comp["fit"] = round(best.fit.score / 5, 3)
                where = f"{best.quant.quant} from {best.repo.split('/')[0]}"
                reasons.append(f"{best.fit.label}: {where} ({(best.quant.size or 0) / 1e9:.1f} GB)"
                               + (" — estimated from the parameter count" if best.estimated else ""))
            comp["capacity"] = self._capacity(g, best, q)
            comp["popularity"] = round(math.log1p(g.downloads) / math.log1p(max_dl), 3)
            age = _age_days(g.created) or _age_days(g.modified)
            comp["recency"] = round(math.exp(-age / 120), 3) if age is not None else 0.3
            c = community.get(g.key)
            if c and (c["feedback"] or c["benchmarks"]):
                share = c["positive_share"] if c["positive_share"] is not None else 0.6
                comp["community"] = round(min(1.0, (c["feedback"] + c["benchmarks"]) / 5) * share, 3)
                reasons.append(f"{c['feedback']} report(s), {c['benchmarks']} benchmark(s) from the community")
            else:
                comp["community"] = 0.0
            total = 100 * sum(WEIGHTS[k] * v for k, v in comp.items())
            out.append(Scored(g, best, opts, total, comp, reasons, bool(det and det.repos), det.repos if det else None))
        return out


def sort_results(scored: list[Scored], sort: str, community: dict | None = None) -> list[Scored]:
    if sort == "popular":
        return sorted(scored, key=lambda s: -s.group.downloads)
    if sort == "recent":
        return sorted(scored, key=lambda s: s.group.created or s.group.modified or "", reverse=True)
    if sort == "community":
        c = community or {}
        return sorted(scored, key=lambda s: (-(c.get(s.group.key, {}).get("feedback", 0)
                                                + c.get(s.group.key, {}).get("benchmarks", 0)), -s.score))
    return sorted(scored, key=lambda s: (-s.score, -s.group.downloads))