codelion commited on
Commit
13b1a91
·
verified ·
1 Parent(s): bb0d191

Deploy MLX Model Explorer (private test)

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ .cache
3
+ .runtime
4
+ .runtime-server.log
5
+ .pytest_cache
6
+ **/__pycache__
7
+ tests
8
+ scripts
9
+ dataset_card
DEPLOY.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying MLX Model Explorer
2
+
3
+ The Space and dataset are created **private**, verified end to end, and only then made public.
4
+
5
+ Prerequisites: `hf auth login` as a member of `mlx-community` with write access, plus the project venv (`uv pip install -r requirements.txt`).
6
+
7
+ ## 1. Verify locally
8
+
9
+ ```bash
10
+ .venv/bin/python -m pytest
11
+ EXPLORER_SINK=local EXPLORER_FLUSH_SECONDS=20 .venv/bin/uvicorn app.main:app --port 7860
12
+ # walk through the UI; events land in .runtime/local_dataset/data/events/
13
+ docker build --platform linux/amd64 -t mlx-model-explorer . \
14
+ && docker run --rm -p 7860:7860 -e EXPLORER_SINK=local mlx-model-explorer
15
+ ```
16
+
17
+ ## 2. Create private repos
18
+
19
+ ```bash
20
+ .venv/bin/python scripts/deploy.py status
21
+ .venv/bin/python scripts/deploy.py create # refuses if the Space exists; add --allow-existing-dataset if the dataset was created earlier
22
+ ```
23
+
24
+ This creates `mlx-community/mlx-model-explorer-data` (private dataset, card uploaded) and `codelion/mlx-model-explorer` (private Docker Space with `EXPLORER_SINK=hub`, `DATASET_REPO`, `EXPLORER_FLUSH_SECONDS=600`).
25
+
26
+ > **Why the Space isn't in `mlx-community`:** Hugging Face now requires a Team or Enterprise plan for an organization to run Docker or Gradio Spaces on free CPU (`402 Payment Required`). The dataset can live in the org. Once an org admin has a plan or a hardware grant, deploy there with `SPACE_REPO=mlx-community/mlx-model-explorer scripts/deploy.py create --allow-existing-dataset` followed by `upload`.
27
+
28
+ ## 3. Dataset write token
29
+
30
+ Create a **fine-grained** token at https://huggingface.co/settings/tokens with *write* permission on `mlx-community/mlx-model-explorer-data` only. Don't use a personal all-access token: anything in a Space secret is available to the running app.
31
+
32
+ ```bash
33
+ HF_DATASET_WRITE_TOKEN=hf_xxx .venv/bin/python scripts/deploy.py secret
34
+ ```
35
+
36
+ ## 4. Test the ingestion path against the private dataset from this machine
37
+
38
+ ```bash
39
+ EXPLORER_SINK=hub EXPLORER_DATA_PREFIX=data/_test EXPLORER_FLUSH_SECONDS=30 \
40
+ HF_TOKEN=$(cat ~/.cache/huggingface/token) .venv/bin/uvicorn app.main:app --port 7860
41
+ ```
42
+
43
+ ## 5. Upload and test the private Space
44
+
45
+ ```bash
46
+ .venv/bin/python scripts/deploy.py variable EXPLORER_DATA_PREFIX data/_test # keep test events separate
47
+ .venv/bin/python scripts/deploy.py upload
48
+ .venv/bin/python scripts/deploy.py status # wait for RUNNING
49
+ ```
50
+
51
+ Open https://huggingface.co/spaces/codelion/mlx-model-explorer while logged in. Run the full flow, then confirm a shard appears under `data/_test/` in the dataset (after the flush interval, or restart the Space to force a flush).
52
+
53
+ To submit a benchmark to the private Space, use its direct URL and a token:
54
+
55
+ ```bash
56
+ python bench/mlx_explorer_bench.py --model mlx-community/Qwen3-0.6B-4bit \
57
+ --submit https://codelion-mlx-model-explorer.hf.space --hf-token $(cat ~/.cache/huggingface/token)
58
+ ```
59
+
60
+ ## 6. Go public (only after review)
61
+
62
+ ```bash
63
+ .venv/bin/python scripts/deploy.py variable EXPLORER_DATA_PREFIX data/events
64
+ .venv/bin/python scripts/deploy.py clean-test-data
65
+ .venv/bin/python scripts/deploy.py publish --yes-make-public
66
+ ```
67
+
68
+ `publish` refuses while `data/_test/` still exists.
69
+
70
+ ## Updating
71
+
72
+ Change code, run the tests, `docker build` locally, then `scripts/deploy.py upload -m "what changed"`. Events are append-only, so redeploys never touch existing data. On shutdown the app flushes buffered events, and anything that fails to upload is retried from the on-disk spool.
73
+
74
+ ## Kill switch
75
+
76
+ `scripts/deploy.py variable EXPLORER_SINK off` stops all collection. The UI keeps working and the event endpoint accepts nothing.
Dockerfile CHANGED
@@ -1,2 +1,23 @@
1
- FROM huggingface/autotrain-advanced:latest
2
- CMD uvicorn autotrain.app:app --host 0.0.0.0 --port 7860 --reload --workers 4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Hugging Face Spaces run containers as uid 1000.
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH \
8
+ PYTHONUNBUFFERED=1 \
9
+ EXPLORER_DATA_DIR=/home/user/runtime \
10
+ EXPLORER_CACHE=/home/user/cache
11
+
12
+ WORKDIR /home/user/app
13
+ COPY --chown=user requirements.txt .
14
+ RUN pip install --no-cache-dir --user -r requirements.txt
15
+
16
+ COPY --chown=user app ./app
17
+ COPY --chown=user static ./static
18
+ COPY --chown=user bench ./bench
19
+ COPY --chown=user data ./data
20
+
21
+ EXPOSE 7860
22
+ # --timeout-graceful-shutdown lets the sink flush buffered events when the Space stops.
23
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips", "*", "--timeout-graceful-shutdown", "20", "--no-access-log"]
README.md CHANGED
@@ -1,10 +1,84 @@
1
  ---
2
- title: AutoTrain Advanced
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MLX Model Explorer
3
+ emoji: 🧭
4
+ colorFrom: gray
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
+ short_description: Find the right MLX model and quantization for your Mac
11
+ tags:
12
+ - mlx
13
+ - apple-silicon
14
+ - quantization
15
+ datasets:
16
+ - mlx-community/mlx-model-explorer-data
17
  ---
18
 
19
+ # MLX Model Explorer
20
+
21
+ Explore MLX models and quantizations for your Mac. Compare `mlx-community` models by memory, quantization, popularity and community experience. You can also contribute anonymous hardware and benchmark data to improve recommendations for everyone.
22
+
23
+ **The question it answers:** given my Mac, model family, size, context length and trade-off, which MLX model and quantization should I use?
24
+
25
+ ## What it does
26
+
27
+ - Lists every model in [`mlx-community`](https://huggingface.co/mlx-community), refreshed from the Hub every 6 hours. Filter by family, size, quantization and context.
28
+ - Estimates memory for each model: exact weight file sizes plus the KV cache at your context (read from `config.json`) plus runtime overhead. It then rates the fit against the GPU-usable memory of your Mac as Comfortable, Likely, Borderline or Unlikely.
29
+ - Ranks results with a transparent score (hardware fit, quantization vs. your priority, context headroom, downloads and likes, recency, community data). Every result explains its score. The score never looks at who uploaded a model or what it is called.
30
+ - Offers an optional **browser hardware estimate** (WebGPU). This is *not* an MLX benchmark; browsers can't run MLX or read unified memory size.
31
+ - Provides an optional **real MLX benchmark** script (`bench/mlx_explorer_bench.py`) you run on your Mac with `mlx-lm`, and optional "how did it work?" reports.
32
+ - Publishes anonymous event data to [`mlx-community/mlx-model-explorer-data`](https://huggingface.co/datasets/mlx-community/mlx-model-explorer-data), plus aggregate stats at `/stats`.
33
+
34
+ ## Privacy
35
+
36
+ No login, cookies, IP addresses, user-agent strings or fingerprints are stored. A random per-tab session ID counts sessions and is discarded when the tab closes. Browsers sending Global Privacy Control or Do Not Track send nothing unless the user opts in, and anyone can opt out on the page. Aggregates with fewer than 5 events are folded into "other". See the dataset card for the full schema.
37
+
38
+ ## Architecture
39
+
40
+ ```
41
+ static/ index.html, app.js (UI + batched anonymous events), webgpu.js (browser estimate), stats page
42
+ app/ FastAPI
43
+ catalogue.py mlx-community listing, 6h refresh, disk cache + bundled snapshot fallback
44
+ parsing.py family / size / quantization / architecture from tags, config.json and names
45
+ model_detail.py lazy config.json + file sizes per model (cached, parallel, timeout-bounded)
46
+ memory.py weights + KV cache + overhead, GPU-usable memory, fit classes
47
+ recommend.py RecommendationEngine interface + HeuristicEngine v1
48
+ events.py strict event schema, bounds, note scrubbing, plausibility flags
49
+ sink.py append-only Parquet shards: local directory (dev) or Hub dataset (prod)
50
+ stats.py k-anonymous aggregates + per-model community signals
51
+ bench/ mlx_explorer_bench.py (runs locally with mlx-lm; submits only with --submit)
52
+ data/ catalogue_snapshot.json.gz (fallback when the Hub API is unreachable)
53
+ tests/ pytest suite
54
+ ```
55
+
56
+ Events are buffered and written as **one Parquet shard per flush** (default every 10 minutes, only when there is data) under `data/events/YYYY/MM/DD/`. That caps Hub commits at about 144 a day. Shards are never rewritten, and a failed upload stays spooled on disk and retries with backoff.
57
+
58
+ ## Configuration
59
+
60
+ | Env var | Default | Meaning |
61
+ | --- | --- | --- |
62
+ | `EXPLORER_SINK` | `local` | `hub` (write to the dataset), `local` (write Parquet under `EXPLORER_DATA_DIR`), `off` (collect nothing) |
63
+ | `DATASET_REPO` | `mlx-community/mlx-model-explorer-data` | dataset for `hub` mode |
64
+ | `HF_TOKEN` | (none) | **Space secret**, a fine-grained token with write access to the dataset only. It is never used for catalogue requests |
65
+ | `HF_READ_TOKEN` | (none) | optional token for Hub reads (rate limits only) |
66
+ | `EXPLORER_FLUSH_SECONDS` | `600` | flush interval |
67
+ | `EXPLORER_DATA_PREFIX` | `data/events` | path prefix inside the dataset (tests use `data/_test`) |
68
+ | `EXPLORER_ORGS` | `mlx-community` | organizations to list |
69
+ | `EXPLORER_RATE_PER_MIN` | `120` | event requests per client per minute |
70
+
71
+ ## Develop and test locally
72
+
73
+ ```bash
74
+ uv venv .venv --python 3.12 && uv pip install --python .venv/bin/python -r requirements.txt pytest
75
+ .venv/bin/python -m pytest # unit + API tests
76
+ EXPLORER_SINK=local .venv/bin/uvicorn app.main:app --port 7860
77
+ open http://localhost:7860 # events land in .runtime/local_dataset/
78
+
79
+ docker build -t mlx-model-explorer . && docker run --rm -p 7860:7860 -e EXPLORER_SINK=local mlx-model-explorer
80
+ ```
81
+
82
+ ## Deploy
83
+
84
+ See [DEPLOY.md](DEPLOY.md). The Space and dataset are created **private**, tested end to end, and only made public once reviewed.
app/catalogue.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cached view of the mlx-community model catalogue.
2
+
3
+ The whole org listing is ~6 paged requests, so we fetch it all on startup and
4
+ every few hours, and serve every UI interaction from memory. If the Hub is down
5
+ we fall back to the last good copy on disk, then to the snapshot bundled with
6
+ the Space.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ import time
17
+ from collections import Counter
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Callable
22
+
23
+ import httpx
24
+
25
+ from .parsing import (
26
+ LLM_PIPELINES,
27
+ base_model_of,
28
+ detect_family,
29
+ detect_params,
30
+ detect_quant,
31
+ param_bucket,
32
+ quant_bucket,
33
+ )
34
+
35
+ log = logging.getLogger("explorer.catalogue")
36
+
37
+ ROOT = Path(__file__).resolve().parent.parent
38
+ BUNDLED_SNAPSHOT = ROOT / "data" / "catalogue_snapshot.json.gz"
39
+ LIST_FIELDS = ["downloads", "likes", "lastModified", "createdAt", "tags", "safetensors",
40
+ "pipeline_tag", "library_name", "gated", "private"]
41
+
42
+
43
+ def hf_endpoint() -> str:
44
+ return os.environ.get("HF_ENDPOINT", "https://huggingface.co").rstrip("/")
45
+
46
+
47
+ def orgs() -> list[str]:
48
+ raw = os.environ.get("EXPLORER_ORGS", "mlx-community")
49
+ return [o.strip() for o in raw.split(",") if o.strip()]
50
+
51
+
52
+ @dataclass
53
+ class ModelRecord:
54
+ id: str
55
+ name: str
56
+ family: str
57
+ pipeline: str | None
58
+ is_llm: bool
59
+ downloads: int
60
+ likes: int
61
+ created: str | None
62
+ modified: str | None
63
+ base_model: str | None
64
+ params: float | None
65
+ active_params: float | None
66
+ params_source: str
67
+ moe: bool
68
+ size_bucket: str | None
69
+ partial: bool
70
+ quant: dict
71
+ quant_bucket: str
72
+ tags: list[str] = field(default_factory=list)
73
+
74
+ def public(self) -> dict:
75
+ d = self.__dict__.copy()
76
+ d["url"] = f"https://huggingface.co/{self.id}"
77
+ return d
78
+
79
+
80
+ def _num(v: Any) -> int:
81
+ return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0 else 0
82
+
83
+
84
+ def build_record(raw: dict) -> ModelRecord | None:
85
+ """One malformed entry must never take down the catalogue."""
86
+ try:
87
+ mid = raw.get("id") or raw.get("modelId")
88
+ if not isinstance(mid, str) or "/" not in mid:
89
+ return None
90
+ if raw.get("private") is True:
91
+ return None # never list private repos, whatever token fetched them
92
+ tags = [t for t in (raw.get("tags") or []) if isinstance(t, str)]
93
+ quant = detect_quant(mid, tags)
94
+ params = detect_params(mid, raw.get("safetensors"), bits=quant.bits)
95
+ pipeline = raw.get("pipeline_tag") if isinstance(raw.get("pipeline_tag"), str) else None
96
+ return ModelRecord(
97
+ id=mid,
98
+ name=mid.split("/", 1)[1],
99
+ family=detect_family(mid, tags),
100
+ pipeline=pipeline,
101
+ is_llm=pipeline in LLM_PIPELINES or (pipeline is None and "conversational" in tags),
102
+ downloads=_num(raw.get("downloads")),
103
+ likes=_num(raw.get("likes")),
104
+ created=raw.get("createdAt") if isinstance(raw.get("createdAt"), str) else None,
105
+ modified=raw.get("lastModified") if isinstance(raw.get("lastModified"), str) else None,
106
+ base_model=base_model_of(tags),
107
+ params=params.total,
108
+ active_params=params.active,
109
+ params_source=params.source,
110
+ moe=params.moe,
111
+ size_bucket=param_bucket(params.total),
112
+ partial=params.partial,
113
+ quant=quant.to_dict(),
114
+ quant_bucket=quant_bucket(quant),
115
+ tags=[t for t in tags if not t.startswith(("region:", "base_model:"))][:20],
116
+ )
117
+ except Exception: # pragma: no cover - defensive
118
+ log.warning("skipping malformed model entry", exc_info=True)
119
+ return None
120
+
121
+
122
+ Fetcher = Callable[[], list[dict]]
123
+
124
+
125
+ def fetch_listing(client: httpx.Client | None = None, timeout: float = 30.0) -> list[dict]:
126
+ own = client is None
127
+ client = client or httpx.Client(timeout=timeout, headers=_auth_headers())
128
+ try:
129
+ out: list[dict] = []
130
+ for org in orgs():
131
+ url = f"{hf_endpoint()}/api/models"
132
+ params: list[tuple[str, str]] | None = [("author", org), ("limit", "1000")] + [
133
+ ("expand[]", f) for f in LIST_FIELDS
134
+ ]
135
+ pages = 0
136
+ while url and pages < 50:
137
+ resp = _get_with_backoff(client, url, params)
138
+ out.extend(x for x in resp.json() if isinstance(x, dict))
139
+ url = resp.links.get("next", {}).get("url")
140
+ params = None
141
+ pages += 1
142
+ return out
143
+ finally:
144
+ if own:
145
+ client.close()
146
+
147
+
148
+ def _auth_headers() -> dict:
149
+ # Deliberately NOT HF_TOKEN: the dataset write token may also see private org repos,
150
+ # which must never appear in a public catalogue. HF_READ_TOKEN is only for rate limits.
151
+ tok = os.environ.get("HF_READ_TOKEN")
152
+ return {"Authorization": f"Bearer {tok}"} if tok else {}
153
+
154
+
155
+ def _get_with_backoff(client: httpx.Client, url: str, params=None, tries: int = 4) -> httpx.Response:
156
+ delay = 2.0
157
+ for attempt in range(tries):
158
+ resp = client.get(url, params=params)
159
+ if resp.status_code == 429 or resp.status_code >= 500:
160
+ if attempt == tries - 1:
161
+ resp.raise_for_status()
162
+ retry_after = resp.headers.get("Retry-After")
163
+ wait = float(retry_after) if retry_after and retry_after.isdigit() else delay
164
+ time.sleep(min(wait, 60))
165
+ delay *= 2
166
+ continue
167
+ resp.raise_for_status()
168
+ return resp
169
+ raise RuntimeError("unreachable")
170
+
171
+
172
+ class Catalogue:
173
+ def __init__(
174
+ self,
175
+ fetcher: Fetcher = fetch_listing,
176
+ cache_dir: Path | None = None,
177
+ refresh_seconds: float = 6 * 3600,
178
+ snapshot: Path = BUNDLED_SNAPSHOT,
179
+ ):
180
+ self._fetcher = fetcher
181
+ self._cache_file = (cache_dir or Path(os.environ.get("EXPLORER_CACHE", ROOT / ".cache"))) / "catalogue.json.gz"
182
+ self._snapshot = snapshot
183
+ self.refresh_seconds = refresh_seconds
184
+ self._lock = threading.Lock()
185
+ self.records: list[ModelRecord] = []
186
+ self.by_id: dict[str, ModelRecord] = {}
187
+ self.source = "empty" # live | disk-cache | snapshot | empty
188
+ self.loaded_at: float | None = None
189
+ self.last_error: str | None = None
190
+ self.skipped = 0
191
+
192
+ # -- loading
193
+ def load_offline(self) -> None:
194
+ """Serve something immediately (last cache, else bundled snapshot) while the live fetch runs."""
195
+ if self.records:
196
+ return
197
+ for path, src in ((self._cache_file, "disk-cache"), (self._snapshot, "snapshot")):
198
+ raw = _read_gz(path)
199
+ if raw:
200
+ self._install(raw, src)
201
+ return
202
+
203
+ def refresh(self) -> None:
204
+ try:
205
+ raw = self._fetcher()
206
+ if not raw:
207
+ raise RuntimeError("empty listing")
208
+ self._install(raw, "live")
209
+ self._write_cache(raw)
210
+ self.last_error = None
211
+ return
212
+ except Exception as e:
213
+ self.last_error = f"{type(e).__name__}: {e}"[:300]
214
+ log.warning("catalogue refresh failed: %s", self.last_error)
215
+ self.load_offline() # keeps serving what we have, or falls back
216
+
217
+ def _install(self, raw: list[dict], source: str) -> None:
218
+ recs = [r for r in (build_record(x) for x in raw) if r is not None]
219
+ with self._lock:
220
+ self.records = recs
221
+ self.by_id = {r.id: r for r in recs}
222
+ self.source = source
223
+ self.loaded_at = time.time()
224
+ self.skipped = len(raw) - len(recs)
225
+
226
+ def _write_cache(self, raw: list[dict]) -> None:
227
+ try:
228
+ self._cache_file.parent.mkdir(parents=True, exist_ok=True)
229
+ tmp = self._cache_file.with_suffix(".tmp")
230
+ with gzip.open(tmp, "wt") as f:
231
+ json.dump(raw, f)
232
+ tmp.replace(self._cache_file)
233
+ except OSError:
234
+ log.warning("could not write catalogue cache", exc_info=True)
235
+
236
+ def start_background_refresh(self) -> threading.Thread:
237
+ def loop():
238
+ while True:
239
+ time.sleep(self.refresh_seconds)
240
+ self.refresh()
241
+
242
+ t = threading.Thread(target=loop, daemon=True, name="catalogue-refresh")
243
+ t.start()
244
+ return t
245
+
246
+ # -- queries
247
+ @property
248
+ def degraded(self) -> bool:
249
+ return self.source != "live"
250
+
251
+ def families(self, llm_only: bool = True, min_count: int = 8) -> list[dict]:
252
+ c = Counter(r.family for r in self.records if (r.is_llm or not llm_only))
253
+ fams = [{"family": f, "count": n} for f, n in c.most_common() if n >= min_count and f != "Other"]
254
+ other = sum(n for f, n in c.items() if n < min_count or f == "Other")
255
+ if other:
256
+ fams.append({"family": "Other", "count": other})
257
+ return fams
258
+
259
+ def matching(
260
+ self,
261
+ family: str | None = None,
262
+ size: str | None = None,
263
+ quant: str | None = None,
264
+ llm_only: bool = True,
265
+ search: str | None = None,
266
+ ) -> list[ModelRecord]:
267
+ known = {f["family"] for f in self.families(llm_only)} - {"Other"}
268
+ out = []
269
+ s = (search or "").strip().lower()
270
+ for r in self.records:
271
+ if llm_only and not r.is_llm:
272
+ continue
273
+ if family:
274
+ if family == "Other":
275
+ if r.family in known:
276
+ continue
277
+ elif r.family != family:
278
+ continue
279
+ if size:
280
+ if size == "MoE":
281
+ if not r.moe:
282
+ continue
283
+ elif size == "unknown":
284
+ if r.size_bucket is not None:
285
+ continue
286
+ elif r.size_bucket != size:
287
+ continue
288
+ if quant and r.quant_bucket != quant:
289
+ continue
290
+ if s and s not in r.id.lower():
291
+ continue
292
+ out.append(r)
293
+ return out
294
+
295
+ def siblings(self, rec: ModelRecord, limit: int = 12) -> list[ModelRecord]:
296
+ """Other quantizations of the same base model."""
297
+ if not rec.base_model:
298
+ return []
299
+ sib = sorted((r for r in self.records if r.base_model == rec.base_model and r.id != rec.id and not r.partial),
300
+ key=lambda r: -r.downloads)
301
+ # The most downloaded repo of each bit width first, so 3/6/8-bit variants aren't
302
+ # crowded out by many 4-bit fine-tunes; then everything else by downloads.
303
+ best, seen = [], set()
304
+ for r in sib:
305
+ if r.quant_bucket not in seen:
306
+ seen.add(r.quant_bucket)
307
+ best.append(r)
308
+ rest = [r for r in sib if r not in best]
309
+ best.sort(key=lambda r: (r.quant["bits"] or 99, -r.downloads))
310
+ return (best + rest)[:limit]
311
+
312
+ def status(self) -> dict:
313
+ return {
314
+ "models": len(self.records),
315
+ "source": self.source,
316
+ "degraded": self.degraded,
317
+ "loaded_at": datetime.fromtimestamp(self.loaded_at, timezone.utc).isoformat() if self.loaded_at else None,
318
+ "last_error": self.last_error,
319
+ "skipped_malformed": self.skipped,
320
+ }
321
+
322
+
323
+ def _read_gz(path: Path) -> list[dict] | None:
324
+ try:
325
+ with gzip.open(path, "rt") as f:
326
+ data = json.load(f)
327
+ return data if isinstance(data, list) else None
328
+ except (OSError, ValueError):
329
+ return None
app/events.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Event schema, validation and server-side enrichment.
2
+
3
+ Clients send a small, typed event. Anything outside the schema is rejected
4
+ (`extra="forbid"`), numbers are bounded, free text is capped and scrubbed, and the
5
+ server fills in catalogue facts itself instead of trusting the client. Values that
6
+ are allowed but implausible are kept and flagged, so dataset users can filter them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+ from datetime import datetime, timezone
14
+ from typing import Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
17
+
18
+ from .memory import RAM_CLASSES, CONTEXTS, bits_per_weight
19
+ from .parsing import PARAM_BUCKETS, QUANT_BUCKETS
20
+
21
+ SCHEMA_VERSION = 1
22
+
23
+ EventType = Literal[
24
+ "search", "filter", "model_view", "model_select", "model_click", "compare",
25
+ "hardware_test", "browser_benchmark", "feedback", "mlx_benchmark_submission",
26
+ ]
27
+
28
+ CHIP_RE = re.compile(r"^Apple M[1-9]( (Pro|Max|Ultra))?$")
29
+ MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$")
30
+ SESSION_RE = re.compile(r"^[a-f0-9]{16,32}$")
31
+ VERSION_RE = re.compile(r"^[0-9A-Za-z.+_-]{1,32}$")
32
+ SHORT_TOKEN_RE = re.compile(r"^[A-Za-z0-9 ._+-]{1,40}$")
33
+
34
+ _EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
35
+ _URL = re.compile(r"(https?://|www\.)\S+", re.I)
36
+ _LONG_DIGITS = re.compile(r"\+?\d[\d ()-]{7,}\d")
37
+
38
+ MAX_NOTES = 280
39
+ MAX_BATCH = 50
40
+
41
+
42
+ def clean_notes(text: str | None) -> str | None:
43
+ if text is None:
44
+ return None
45
+ text = unicodedata.normalize("NFKC", str(text))
46
+ text = "".join(ch for ch in text if unicodedata.category(ch)[0] != "C" or ch == " ")
47
+ text = _EMAIL.sub("[email removed]", text)
48
+ text = _URL.sub("[link removed]", text)
49
+ text = _LONG_DIGITS.sub("[number removed]", text)
50
+ text = re.sub(r"\s+", " ", text).strip()
51
+ return text[:MAX_NOTES] or None
52
+
53
+
54
+ class ClientEvent(BaseModel):
55
+ """What the browser (or the benchmark script) is allowed to send."""
56
+
57
+ model_config = ConfigDict(extra="forbid", str_max_length=200)
58
+
59
+ event_type: EventType
60
+ session_id: str | None = None
61
+
62
+ model_family: str | None = Field(None, max_length=40)
63
+ parameter_bucket: Literal[tuple(PARAM_BUCKETS + ["MoE", "unknown"])] | None = None # type: ignore[valid-type]
64
+ quantization: Literal[tuple(QUANT_BUCKETS)] | None = None # type: ignore[valid-type]
65
+ target_context: int | None = None
66
+ priority: Literal["balanced", "quality", "speed", "memory", "long_context"] | None = None
67
+ sort: Literal["recommended", "popular", "recent", "community", "all"] | None = None
68
+ result_count: int | None = Field(None, ge=0, le=100_000)
69
+
70
+ hardware_source: Literal["detected", "confirmed", "none"] | None = None
71
+ hardware_memory_class: int | None = None
72
+ webgpu_available: bool | None = None
73
+ webgpu_score: float | None = Field(None, ge=0, le=1_000_000)
74
+ gpu_capability_class: Literal["high", "mid", "entry", "unknown"] | None = None
75
+ gpu_vendor: str | None = Field(None, max_length=24)
76
+ gpu_arch: str | None = Field(None, max_length=24)
77
+ browser_family: Literal["chrome", "safari", "firefox", "edge", "other"] | None = None
78
+ os_family: Literal["macos", "ios", "windows", "linux", "android", "other"] | None = None
79
+ cpu_cores: int | None = Field(None, ge=1, le=256)
80
+
81
+ selected_model: str | None = None
82
+ selected_model_rank: int | None = Field(None, ge=0, le=10_000)
83
+ # what the user was shown for that model at the time (for studying recommendations)
84
+ recommendation_score: float | None = Field(None, ge=0, le=100)
85
+ fit_class: Literal["Comfortable", "Likely", "Borderline", "Unlikely"] | None = None
86
+ compare_models: list[str] | None = Field(None, max_length=5)
87
+
88
+ tried: Literal["yes", "no", "planning"] | None = None
89
+ quality_rating: Literal["excellent", "good", "acceptable"] | None = None
90
+ failure_reason: Literal["too_slow", "too_much_memory", "low_quality", "didnt_run"] | None = None
91
+ reported_ram_gb: int | None = None
92
+ reported_mac_model: str | None = None
93
+ reported_tokens_per_second: float | None = Field(None, gt=0, le=2000)
94
+ reported_context: int | None = Field(None, ge=128, le=4_194_304)
95
+ notes: str | None = Field(None, max_length=1000)
96
+
97
+ benchmark_type: Literal["webgpu_quick", "webgpu_full", "mlx_lm"] | None = None
98
+ benchmark_version: str | None = None
99
+ benchmark_duration_ms: int | None = Field(None, ge=0, le=600_000)
100
+ prompt_tokens: int | None = Field(None, ge=1, le=1_048_576)
101
+ generation_tokens: int | None = Field(None, ge=1, le=65_536)
102
+ prompt_tps: float | None = Field(None, gt=0, le=100_000)
103
+ generation_tps: float | None = Field(None, gt=0, le=2000)
104
+ ttft_ms: float | None = Field(None, ge=0, le=600_000)
105
+ peak_memory_gb: float | None = Field(None, gt=0, le=1024)
106
+ chip: str | None = None
107
+ mlx_version: str | None = None
108
+ mlx_lm_version: str | None = None
109
+ macos_major: int | None = Field(None, ge=11, le=40)
110
+
111
+ @field_validator("session_id")
112
+ @classmethod
113
+ def _session(cls, v):
114
+ if v is not None and not SESSION_RE.match(v):
115
+ raise ValueError("session_id must be 16-32 lowercase hex chars")
116
+ return v
117
+
118
+ @field_validator("selected_model")
119
+ @classmethod
120
+ def _model(cls, v):
121
+ if v is not None and not MODEL_ID_RE.match(v):
122
+ raise ValueError("invalid model id")
123
+ return v
124
+
125
+ @field_validator("compare_models")
126
+ @classmethod
127
+ def _models(cls, v):
128
+ if v is not None:
129
+ for m in v:
130
+ if not MODEL_ID_RE.match(m):
131
+ raise ValueError("invalid model id in compare_models")
132
+ return v
133
+
134
+ @field_validator("target_context")
135
+ @classmethod
136
+ def _ctx(cls, v):
137
+ if v is not None and v not in CONTEXTS:
138
+ raise ValueError(f"target_context must be one of {CONTEXTS}")
139
+ return v
140
+
141
+ @field_validator("hardware_memory_class", "reported_ram_gb")
142
+ @classmethod
143
+ def _ram(cls, v):
144
+ if v is not None and v not in RAM_CLASSES:
145
+ raise ValueError(f"memory must be one of {RAM_CLASSES}")
146
+ return v
147
+
148
+ @field_validator("chip", "reported_mac_model")
149
+ @classmethod
150
+ def _chip(cls, v):
151
+ if v is not None and v != "other" and not CHIP_RE.match(v):
152
+ raise ValueError("chip must look like 'Apple M3 Max' or be 'other'")
153
+ return v
154
+
155
+ @field_validator("benchmark_version", "mlx_version", "mlx_lm_version")
156
+ @classmethod
157
+ def _ver(cls, v):
158
+ if v is not None and not VERSION_RE.match(v):
159
+ raise ValueError("invalid version string")
160
+ return v
161
+
162
+ @field_validator("model_family", "gpu_vendor", "gpu_arch")
163
+ @classmethod
164
+ def _short(cls, v):
165
+ if v is not None and not SHORT_TOKEN_RE.match(v):
166
+ raise ValueError("invalid token")
167
+ return v
168
+
169
+ @field_validator("notes")
170
+ @classmethod
171
+ def _notes(cls, v):
172
+ return clean_notes(v)
173
+
174
+
175
+ class EventBatch(BaseModel):
176
+ model_config = ConfigDict(extra="forbid")
177
+ events: list[ClientEvent] = Field(..., min_length=1, max_length=MAX_BATCH)
178
+
179
+
180
+ # Column order for the dataset. Every row has every column (null when unused).
181
+ COLUMNS: dict[str, str] = {
182
+ "timestamp": "string", "schema_version": "int", "app_version": "string", "event_type": "string",
183
+ "session_id": "string",
184
+ "model_family": "string", "model_name": "string", "parameter_bucket": "string", "quantization": "string",
185
+ "quant_bits": "float", "quant_mixed": "bool",
186
+ "target_context": "int", "priority": "string", "sort": "string", "result_count": "int",
187
+ "hardware_source": "string", "hardware_memory_class": "int", "hardware_confirmed": "bool",
188
+ "webgpu_available": "bool", "webgpu_score": "float", "gpu_capability_class": "string",
189
+ "gpu_vendor": "string", "gpu_arch": "string", "browser_family": "string", "os_family": "string",
190
+ "cpu_cores": "int",
191
+ "selected_model": "string", "selected_model_rank": "int", "compare_models": "list",
192
+ "hf_downloads_at_selection": "int", "hf_likes_at_selection": "int",
193
+ "recommendation_score": "float", "fit_class": "string", "engine_version": "string",
194
+ "pipeline": "string",
195
+ "tried": "string", "outcome": "string", "quality_rating": "string", "failure_reason": "string",
196
+ "reported_ram_gb": "int", "reported_mac_model": "string", "reported_tokens_per_second": "float",
197
+ "reported_context": "int", "notes": "string",
198
+ "benchmark_type": "string", "benchmark_version": "string", "benchmark_duration_ms": "int",
199
+ "prompt_tokens": "int", "generation_tokens": "int", "prompt_tps": "float", "generation_tps": "float",
200
+ "ttft_ms": "float", "peak_memory_gb": "float", "chip": "string", "mlx_version": "string",
201
+ "mlx_lm_version": "string", "macos_major": "int",
202
+ "suspicious_flags": "list",
203
+ }
204
+
205
+
206
+ def utc_now_seconds() -> str:
207
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
208
+
209
+
210
+ def to_row(ev: ClientEvent, catalogue=None, app_version: str = "dev", engine_version: str | None = None) -> dict:
211
+ """Validated client event -> dataset row, with server-side facts and suspicion flags."""
212
+ d = ev.model_dump()
213
+ row = {k: None for k in COLUMNS}
214
+ for k, v in d.items():
215
+ if k in row:
216
+ row[k] = v
217
+ row["timestamp"] = utc_now_seconds()
218
+ row["schema_version"] = SCHEMA_VERSION
219
+ row["app_version"] = app_version
220
+ row["engine_version"] = engine_version
221
+ row["hardware_confirmed"] = ev.hardware_source == "confirmed" if ev.hardware_source else None
222
+ if ev.tried == "yes":
223
+ if ev.failure_reason:
224
+ row["outcome"] = "problem"
225
+ elif ev.quality_rating:
226
+ row["outcome"] = "worked"
227
+ flags: list[str] = []
228
+
229
+ rec = None
230
+ if ev.selected_model and catalogue is not None:
231
+ rec = catalogue.by_id.get(ev.selected_model)
232
+ if rec is None:
233
+ flags.append("unknown_model")
234
+ else:
235
+ row["model_name"] = rec.name
236
+ row["model_family"] = row["model_family"] or rec.family
237
+ row["parameter_bucket"] = row["parameter_bucket"] or rec.size_bucket
238
+ row["quantization"] = row["quantization"] or rec.quant_bucket
239
+ row["quant_bits"] = rec.quant.get("bits")
240
+ row["quant_mixed"] = rec.quant.get("mixed")
241
+ row["hf_downloads_at_selection"] = rec.downloads
242
+ row["hf_likes_at_selection"] = rec.likes
243
+ row["pipeline"] = rec.pipeline
244
+ if ev.compare_models and catalogue is not None:
245
+ if any(m not in catalogue.by_id for m in ev.compare_models):
246
+ flags.append("unknown_model_in_compare")
247
+
248
+ flags += plausibility_flags(ev, rec)
249
+ row["suspicious_flags"] = sorted(set(flags))
250
+ return row
251
+
252
+
253
+ # Apple Silicon memory bandwidth tops out around 800 GB/s; decode speed is bounded
254
+ # by bandwidth / bytes read per token. Allow 3x headroom for speculative decoding.
255
+ MAX_BANDWIDTH_GBPS = 800
256
+ TPS_HEADROOM = 3.0
257
+
258
+
259
+ def plausibility_flags(ev: ClientEvent, rec=None) -> list[str]:
260
+ flags = []
261
+ tps_values = [v for v in (ev.generation_tps, ev.reported_tokens_per_second) if v]
262
+ if rec is not None and tps_values:
263
+ active = rec.active_params or rec.params
264
+ bpw = bits_per_weight(rec.quant.get("bits"), rec.quant.get("mode"))
265
+ if active and bpw:
266
+ gb_per_token = active * bpw / 8 / 1e9
267
+ ceiling = MAX_BANDWIDTH_GBPS / max(gb_per_token, 0.05) * TPS_HEADROOM
268
+ if max(tps_values) > ceiling:
269
+ flags.append("implausible_tps")
270
+ ram = ev.reported_ram_gb or ev.hardware_memory_class
271
+ if ev.peak_memory_gb and ram and ev.peak_memory_gb > ram * 1.05:
272
+ flags.append("peak_memory_exceeds_ram")
273
+ if ev.event_type == "mlx_benchmark_submission":
274
+ if not (ev.selected_model and ev.generation_tps and ev.benchmark_version):
275
+ flags.append("incomplete_benchmark")
276
+ if ev.benchmark_type not in (None, "mlx_lm"):
277
+ flags.append("benchmark_type_mismatch")
278
+ if ev.event_type == "browser_benchmark" and ev.benchmark_type == "mlx_lm":
279
+ flags.append("benchmark_type_mismatch")
280
+ if ev.webgpu_score and ev.webgpu_score > 200_000:
281
+ flags.append("implausible_webgpu_score")
282
+ if ev.failure_reason and ev.quality_rating:
283
+ flags.append("conflicting_feedback")
284
+ return flags
app/main.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX Model Explorer: FastAPI backend + static frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import threading
8
+ import time
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from fastapi import FastAPI, HTTPException, Request
14
+ from fastapi.exceptions import RequestValidationError
15
+ from fastapi.responses import FileResponse, JSONResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel, Field, ValidationError
18
+
19
+ from .catalogue import ROOT, Catalogue
20
+ from .events import EventBatch, to_row
21
+ from .memory import CONTEXTS, RAM_CLASSES
22
+ from .model_detail import DetailCache
23
+ from .parsing import PARAM_BUCKETS, QUANT_BUCKETS
24
+ from .ratelimit import RateLimiter
25
+ from .recommend import PRIORITIES, HeuristicEngine, Query, sort_results
26
+ from .sink import make_sink
27
+ from .stats import StatsCache
28
+
29
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
30
+ log = logging.getLogger("explorer")
31
+ # Never log client addresses: silence uvicorn's per-request access log.
32
+ logging.getLogger("uvicorn.access").disabled = True
33
+ logging.getLogger("httpx").setLevel(logging.WARNING) # per-request Hub fetch logs are noise
34
+
35
+ APP_VERSION = os.environ.get("EXPLORER_VERSION", "0.1.0")
36
+ STATIC = ROOT / "static"
37
+ MAX_BODY = 64 * 1024
38
+ DETAIL_TOP_N = int(os.environ.get("EXPLORER_DETAIL_TOP_N", "24"))
39
+ PREWARM_N = int(os.environ.get("EXPLORER_PREWARM", "300"))
40
+
41
+
42
+ class RecommendRequest(BaseModel):
43
+ family: str | None = Field(None, max_length=40)
44
+ size: str | None = Field(None, max_length=10)
45
+ quant: str | None = Field(None, max_length=10)
46
+ context: int = 8192
47
+ ram_gb: float | None = Field(None, gt=0, le=1024)
48
+ ram_source: Literal["confirmed", "estimated"] | None = None
49
+ priority: Literal["balanced", "quality", "speed", "memory", "long_context"] = "balanced"
50
+ sort: Literal["recommended", "popular", "recent", "community", "all"] = "recommended"
51
+ llm_only: bool = True
52
+ search: str | None = Field(None, max_length=80)
53
+ offset: int = Field(0, ge=0, le=100_000)
54
+ limit: int = Field(24, ge=1, le=100)
55
+
56
+
57
+ class State:
58
+ """Everything a request needs; swapped for fakes in tests."""
59
+
60
+ def __init__(self, catalogue=None, details=None, sink=None, fetch_live=True):
61
+ base = Path(os.environ.get("EXPLORER_DATA_DIR", ROOT / ".runtime"))
62
+ self.catalogue = catalogue or Catalogue()
63
+ self.details = details or DetailCache()
64
+ self.sink = sink or make_sink(base)
65
+ self.engine = HeuristicEngine()
66
+ self.limiter = RateLimiter(rate_per_minute=float(os.environ.get("EXPLORER_RATE_PER_MIN", "120")), burst=60)
67
+ self.existing_rows: list[dict] = []
68
+ self.stats = StatsCache(lambda: self.existing_rows + self.sink.all_rows)
69
+ self.fetch_live = fetch_live
70
+ self.collection_enabled = os.environ.get("EXPLORER_SINK", "local") != "off"
71
+
72
+ def prewarm(self):
73
+ """Fetch config + file sizes for the most downloaded LLMs, gently, so first results are exact."""
74
+ top = sorted((r for r in self.catalogue.records if r.is_llm), key=lambda r: -r.downloads)[:PREWARM_N]
75
+ for r in top:
76
+ try:
77
+ if self.details.peek(r.id) is None:
78
+ self.details.get(r.id)
79
+ time.sleep(0.25)
80
+ except Exception:
81
+ pass
82
+
83
+ def boot(self):
84
+ self.catalogue.load_offline()
85
+ if self.fetch_live:
86
+ def initial():
87
+ self.catalogue.refresh()
88
+ self.prewarm()
89
+
90
+ threading.Thread(target=initial, daemon=True, name="catalogue-initial").start()
91
+ self.catalogue.start_background_refresh()
92
+ self.sink.start()
93
+
94
+ def load_existing():
95
+ self.existing_rows = self.sink.read_existing()
96
+ self.stats.invalidate()
97
+
98
+ threading.Thread(target=load_existing, daemon=True, name="load-existing").start()
99
+
100
+
101
+ def create_app(state: State | None = None) -> FastAPI:
102
+ st = state or State()
103
+
104
+ @asynccontextmanager
105
+ async def lifespan(app):
106
+ if state is None:
107
+ st.boot()
108
+ yield
109
+ st.sink.flush() # don't lose buffered events on shutdown
110
+
111
+ app = FastAPI(title="MLX Model Explorer", version=APP_VERSION, lifespan=lifespan,
112
+ docs_url=None, redoc_url=None, openapi_url=None)
113
+ app.state.st = st
114
+
115
+ @app.middleware("http")
116
+ async def guard(request: Request, call_next):
117
+ cl = request.headers.get("content-length")
118
+ if cl and cl.isdigit() and int(cl) > MAX_BODY:
119
+ return JSONResponse({"error": "payload too large"}, status_code=413)
120
+ resp = await call_next(request)
121
+ resp.headers["X-Content-Type-Options"] = "nosniff"
122
+ resp.headers["Referrer-Policy"] = "no-referrer"
123
+ resp.headers["Content-Security-Policy"] = (
124
+ "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
125
+ "connect-src 'self'; frame-ancestors https://huggingface.co https://*.hf.space 'self'"
126
+ )
127
+ return resp
128
+
129
+ @app.exception_handler(RequestValidationError)
130
+ async def _invalid(request, exc):
131
+ errs = [{"loc": [str(x) for x in e.get("loc", [])][-3:], "msg": str(e.get("msg"))[:160]}
132
+ for e in exc.errors()[:10]]
133
+ return JSONResponse({"error": "invalid request", "details": errs}, status_code=422)
134
+
135
+ def client_key(request: Request) -> str:
136
+ fwd = request.headers.get("x-forwarded-for", "")
137
+ return fwd.split(",")[0].strip() or (request.client.host if request.client else "unknown")
138
+
139
+ # ------------------------------------------------------------------ pages
140
+ @app.get("/", include_in_schema=False)
141
+ def index():
142
+ return FileResponse(STATIC / "index.html")
143
+
144
+ @app.get("/bench/mlx_explorer_bench.py", include_in_schema=False)
145
+ def bench_script():
146
+ return FileResponse(ROOT / "bench" / "mlx_explorer_bench.py", media_type="text/x-python")
147
+
148
+ @app.get("/stats", include_in_schema=False)
149
+ def stats_page():
150
+ return FileResponse(STATIC / "stats.html")
151
+
152
+ @app.get("/healthz")
153
+ def healthz():
154
+ cat = st.catalogue.status()
155
+ sink = st.sink.status()
156
+ ok = cat["models"] > 0
157
+ return JSONResponse({"ok": ok, "version": APP_VERSION, "catalogue": cat, "sink": sink},
158
+ status_code=200 if ok else 503)
159
+
160
+ # ------------------------------------------------------------------ api
161
+ @app.get("/api/meta")
162
+ def meta(llm_only: bool = True):
163
+ return {
164
+ "version": APP_VERSION,
165
+ "engine": st.engine.version,
166
+ "families": st.catalogue.families(llm_only),
167
+ "sizes": PARAM_BUCKETS + ["MoE"],
168
+ "quantizations": [q for q in QUANT_BUCKETS if q != "unknown"] + ["unknown"],
169
+ "contexts": CONTEXTS,
170
+ "ram_classes": RAM_CLASSES,
171
+ "priorities": PRIORITIES,
172
+ "catalogue": st.catalogue.status(),
173
+ "collection_enabled": st.collection_enabled,
174
+ }
175
+
176
+ @app.post("/api/recommend")
177
+ def recommend(req: RecommendRequest):
178
+ if req.context not in CONTEXTS:
179
+ raise HTTPException(422, "context must be one of " + ",".join(map(str, CONTEXTS)))
180
+ q = Query(req.family or None, req.size or None, req.quant or None, req.context,
181
+ req.ram_gb, req.ram_source, req.priority)
182
+ matches = st.catalogue.matching(q.family, q.size, q.quant, req.llm_only, req.search)
183
+ community = st.stats.community()
184
+ cached = {m.id: d for m in matches if (d := st.details.peek(m.id))}
185
+ norms = st.engine.norms(matches, q, cached)
186
+ prelim = sort_results(st.engine.score(matches, q, cached, community, norms), req.sort)
187
+ window = prelim[: max(DETAIL_TOP_N, req.offset + req.limit)]
188
+ details = st.details.get_many([s.model.id for s in window], timeout=6.0)
189
+ rescored = st.engine.score([s.model for s in window], q, details, community, norms)
190
+ final = sort_results(rescored, req.sort) + prelim[len(window):]
191
+ page = final[req.offset: req.offset + req.limit]
192
+ notices = []
193
+ if st.catalogue.degraded:
194
+ notices.append("Showing a cached copy of the model list; some model information isn't available.")
195
+ missing = sum(1 for s in page if not s.detail_loaded)
196
+ if missing:
197
+ notices.append(f"Some model information isn't available for {missing} result(s); "
198
+ "their memory estimates use parameter counts only.")
199
+ return {
200
+ "total": len(matches),
201
+ "offset": req.offset,
202
+ "results": [s.to_dict() for s in page],
203
+ "notices": notices,
204
+ "engine": st.engine.version,
205
+ }
206
+
207
+ @app.get("/api/model/{model_id:path}")
208
+ def model(model_id: str, context: int = 8192, ram_gb: float | None = None,
209
+ priority: str = "balanced", ram_source: str | None = None):
210
+ rec = st.catalogue.by_id.get(model_id)
211
+ if rec is None:
212
+ raise HTTPException(404, "model not found in the mlx-community catalogue")
213
+ if context not in CONTEXTS:
214
+ context = 8192
215
+ if priority not in PRIORITIES:
216
+ priority = "balanced"
217
+ detail = st.details.get(model_id)
218
+ community = st.stats.community()
219
+ if ram_gb is not None and not (0 < ram_gb <= 1024):
220
+ ram_gb = None
221
+ q = Query(context=context, ram_gb=ram_gb, priority=priority,
222
+ ram_source="confirmed" if ram_source == "confirmed" else ("estimated" if ram_gb else None))
223
+ scored = st.engine.score([rec], q, {model_id: detail}, community)[0]
224
+ by_context = []
225
+ for c in CONTEXTS:
226
+ mem, _ = st.engine.memory_for(rec, Query(context=c, ram_gb=ram_gb), detail)
227
+ by_context.append(mem.to_dict())
228
+ sibs = st.catalogue.siblings(rec)
229
+ sib_details = st.details.get_many([s.id for s in sibs], timeout=4.0)
230
+ sib_scored = st.engine.score(sibs, q, sib_details, community)
231
+ weight_files = [f for f in detail.files if f["path"].endswith((".safetensors", ".npz", ".gguf"))]
232
+ return {
233
+ **scored.to_dict(),
234
+ "files": detail.files[:60],
235
+ "file_count": len(detail.files),
236
+ "weight_files": len(weight_files),
237
+ "detail_error": detail.error,
238
+ "memory_by_context": by_context,
239
+ "siblings": [s.to_dict() for s in sib_scored],
240
+ }
241
+
242
+ @app.post("/api/events", status_code=202)
243
+ async def events(request: Request):
244
+ if not st.limiter.allow(client_key(request)):
245
+ return JSONResponse({"error": "rate limited"}, status_code=429)
246
+ raw = await request.body()
247
+ if len(raw) > MAX_BODY: # also covers chunked uploads with no content-length
248
+ return JSONResponse({"error": "payload too large"}, status_code=413)
249
+ try:
250
+ import json as _json
251
+ body = _json.loads(raw)
252
+ except Exception:
253
+ return JSONResponse({"error": "invalid json"}, status_code=400)
254
+ try:
255
+ batch = EventBatch.model_validate(body)
256
+ except ValidationError as e:
257
+ errs = [{"loc": [str(x) for x in err.get("loc", [])][-3:], "msg": str(err.get("msg"))[:160]}
258
+ for err in e.errors()[:10]]
259
+ return JSONResponse({"error": "invalid events", "details": errs}, status_code=422)
260
+ if not st.collection_enabled:
261
+ return {"accepted": 0}
262
+ rows = [to_row(ev, st.catalogue, APP_VERSION, st.engine.version) for ev in batch.events]
263
+ st.sink.add(rows)
264
+ st.stats.invalidate()
265
+ return {"accepted": len(rows), "flags": [r["suspicious_flags"] for r in rows]}
266
+
267
+ @app.get("/api/stats")
268
+ def stats():
269
+ return st.stats.stats()
270
+
271
+ app.mount("/static", StaticFiles(directory=STATIC), name="static")
272
+ return app
273
+
274
+
275
+ app = create_app() if os.environ.get("EXPLORER_NO_AUTOAPP") != "1" else None
app/memory.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative memory estimates. Every number here is an estimate, not a measurement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, asdict
6
+
7
+ from .parsing import Arch
8
+
9
+ GB = 1024**3
10
+
11
+ RAM_CLASSES = [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192, 256, 512]
12
+ CONTEXTS = [4096, 8192, 16384, 32768, 65536, 131072, 262144]
13
+
14
+ FIT_ORDER = ["Comfortable", "Likely", "Borderline", "Unlikely"]
15
+
16
+
17
+ def bits_per_weight(bits: float | None, mode: str | None = None) -> float | None:
18
+ """Effective storage bits including per-group scales/biases."""
19
+ if bits is None:
20
+ return None
21
+ if bits >= 16:
22
+ return 16.0
23
+ if mode in ("mxfp4", "nvfp4", "mxfp8"):
24
+ return bits + 0.5 # one 8-bit scale per 16-32 weights
25
+ return bits + 0.5 # affine gs64: fp16 scale + bias per 64 weights
26
+
27
+
28
+ def weights_bytes(
29
+ file_bytes: int | None, params: float | None, bits: float | None, mode: str | None = None
30
+ ) -> tuple[int | None, str]:
31
+ if isinstance(file_bytes, (int, float)) and file_bytes > 0:
32
+ return int(file_bytes), "files"
33
+ bpw = bits_per_weight(bits, mode)
34
+ if params and bpw:
35
+ return int(params * bpw / 8), "params"
36
+ return None, "unknown"
37
+
38
+
39
+ def kv_cache_bytes(arch: Arch, context: int, kv_bytes_per_elem: float = 2.0) -> tuple[int | None, bool]:
40
+ """Returns (bytes, is_upper_bound). fp16 K and V for every cached token."""
41
+ if not arch.known:
42
+ return None, True
43
+ per_token_layer = 2 * arch.kv_heads * arch.head_dim * kv_bytes_per_elem
44
+ full = arch.full_attention_layers if arch.full_attention_layers is not None else arch.layers
45
+ total = full * context * per_token_layer
46
+ if arch.sliding_layers and arch.sliding_window:
47
+ total += arch.sliding_layers * min(context, arch.sliding_window) * per_token_layer
48
+ upper = arch.full_attention_layers is None
49
+ return int(total), upper
50
+
51
+
52
+ def usable_gpu_bytes(ram_gb: float) -> int:
53
+ """macOS lets Metal wire roughly 2/3 of RAM on small machines and 3/4 on larger ones
54
+ by default (raisable with `sudo sysctl iogpu.wired_limit_mb`)."""
55
+ frac = 0.67 if ram_gb <= 36 else 0.75
56
+ return int(ram_gb * GB * frac)
57
+
58
+
59
+ def overhead_bytes(weights: int | None) -> int:
60
+ return int(1.0 * GB + 0.05 * (weights or 0))
61
+
62
+
63
+ def fit_class(total_bytes: int | None, ram_gb: float | None) -> str | None:
64
+ if total_bytes is None or not ram_gb:
65
+ return None
66
+ ratio = total_bytes / usable_gpu_bytes(ram_gb)
67
+ if ratio < 0.70:
68
+ return "Comfortable"
69
+ if ratio < 0.85:
70
+ return "Likely"
71
+ if ratio < 1.0:
72
+ return "Borderline"
73
+ return "Unlikely"
74
+
75
+
76
+ @dataclass
77
+ class MemoryEstimate:
78
+ weights_gb: float | None
79
+ weights_source: str
80
+ kv_gb: float | None
81
+ kv_rough: bool
82
+ overhead_gb: float | None
83
+ total_gb: float | None
84
+ context: int
85
+ fit: str | None
86
+ usable_gb: float | None
87
+ exceeds_model_context: bool
88
+ ratio: float | None = None # total / usable GPU memory
89
+
90
+ def to_dict(self) -> dict:
91
+ return asdict(self)
92
+
93
+
94
+ def estimate(
95
+ *,
96
+ params: float | None,
97
+ bits: float | None,
98
+ mode: str | None,
99
+ file_bytes: int | None,
100
+ arch: Arch,
101
+ context: int,
102
+ ram_gb: float | None,
103
+ ) -> MemoryEstimate:
104
+ w, wsrc = weights_bytes(file_bytes, params, bits, mode)
105
+ kv, upper = kv_cache_bytes(arch, context)
106
+ if kv is None and params:
107
+ # No architecture: rule of thumb between GQA (~0.02) and full multi-head (~0.07)
108
+ # models, in MB of fp16 KV per token per billion params.
109
+ kv = int(params / 1e9 * 0.05 * 1024**2 * context)
110
+ upper = True
111
+ oh = overhead_bytes(w) if w is not None else None
112
+ total = (w + (kv or 0) + oh) if w is not None else None
113
+ r = lambda b: None if b is None else round(b / GB, 2)
114
+ return MemoryEstimate(
115
+ weights_gb=r(w),
116
+ weights_source=wsrc,
117
+ kv_gb=r(kv),
118
+ kv_rough=upper,
119
+ overhead_gb=r(oh),
120
+ total_gb=r(total),
121
+ context=context,
122
+ fit=fit_class(total, ram_gb),
123
+ usable_gb=r(usable_gpu_bytes(ram_gb)) if ram_gb else None,
124
+ exceeds_model_context=bool(arch.max_context and context > arch.max_context),
125
+ ratio=round(total / usable_gpu_bytes(ram_gb), 3) if total is not None and ram_gb else None,
126
+ )
app/model_detail.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-model details (config.json + file sizes), fetched lazily and cached.
2
+
3
+ Only models a user actually looks at, or the top of a result list, get fetched.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from dataclasses import dataclass, asdict
16
+ from pathlib import Path
17
+ from typing import Callable
18
+
19
+ import httpx
20
+
21
+ from .catalogue import ROOT, _auth_headers, hf_endpoint
22
+
23
+ log = logging.getLogger("explorer.detail")
24
+
25
+ WEIGHT_SUFFIXES = (".safetensors", ".npz", ".gguf", ".bin")
26
+ MAX_CONFIG_BYTES = 2_000_000
27
+
28
+
29
+ @dataclass
30
+ class ModelDetail:
31
+ id: str
32
+ config: dict | None
33
+ files: list[dict] # [{path, size}]
34
+ weight_bytes: int | None
35
+ fetched_at: float
36
+ error: str | None = None
37
+
38
+ def to_dict(self) -> dict:
39
+ d = asdict(self)
40
+ d.pop("config", None)
41
+ return d
42
+
43
+
44
+ Fetch = Callable[[str], ModelDetail]
45
+
46
+
47
+ def fetch_detail(model_id: str, client: httpx.Client | None = None) -> ModelDetail:
48
+ own = client is None
49
+ client = client or httpx.Client(timeout=15.0, headers=_auth_headers(), follow_redirects=True)
50
+ errors = []
51
+ files: list[dict] = []
52
+ config = None
53
+ try:
54
+ try:
55
+ r = client.get(f"{hf_endpoint()}/api/models/{model_id}", params={"blobs": "true", "expand[]": "siblings"})
56
+ r.raise_for_status()
57
+ for s in r.json().get("siblings") or []:
58
+ if isinstance(s, dict) and isinstance(s.get("rfilename"), str):
59
+ size = s.get("size") if isinstance(s.get("size"), int) else None
60
+ files.append({"path": s["rfilename"][:200], "size": size})
61
+ except Exception as e:
62
+ errors.append(f"files: {type(e).__name__}")
63
+ try:
64
+ r = client.get(f"{hf_endpoint()}/{model_id}/resolve/main/config.json")
65
+ r.raise_for_status()
66
+ if len(r.content) <= MAX_CONFIG_BYTES:
67
+ parsed = r.json()
68
+ config = parsed if isinstance(parsed, dict) else None
69
+ except Exception as e:
70
+ errors.append(f"config: {type(e).__name__}")
71
+ finally:
72
+ if own:
73
+ client.close()
74
+ wb = sum(f["size"] for f in files if f["size"] and f["path"].endswith(WEIGHT_SUFFIXES)) or None
75
+ return ModelDetail(model_id, config, files[:500], wb, time.time(), "; ".join(errors) or None)
76
+
77
+
78
+ class DetailCache:
79
+ def __init__(self, fetch: Fetch | None = None, cache_dir: Path | None = None,
80
+ ttl: float = 24 * 3600, error_ttl: float = 600, workers: int = 12):
81
+ self._client = httpx.Client(timeout=15.0, headers=_auth_headers(), follow_redirects=True)
82
+ self._fetch = fetch or (lambda mid: fetch_detail(mid, self._client))
83
+ self._dir = (cache_dir or Path(os.environ.get("EXPLORER_CACHE", ROOT / ".cache"))) / "details"
84
+ self._mem: dict[str, ModelDetail] = {}
85
+ self._lock = threading.Lock()
86
+ self.ttl, self.error_ttl = ttl, error_ttl
87
+ self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="detail")
88
+
89
+ def _path(self, mid: str) -> Path:
90
+ return self._dir / (hashlib.sha1(mid.encode()).hexdigest() + ".json")
91
+
92
+ def _fresh(self, d: ModelDetail) -> bool:
93
+ ttl = self.error_ttl if d.error else self.ttl
94
+ return time.time() - d.fetched_at < ttl
95
+
96
+ def peek(self, mid: str) -> ModelDetail | None:
97
+ with self._lock:
98
+ d = self._mem.get(mid)
99
+ if d and self._fresh(d):
100
+ return d
101
+ try:
102
+ raw = json.loads(self._path(mid).read_text())
103
+ d = ModelDetail(**raw)
104
+ if self._fresh(d):
105
+ with self._lock:
106
+ self._mem[mid] = d
107
+ return d
108
+ except (OSError, ValueError, TypeError):
109
+ pass
110
+ return None
111
+
112
+ def get(self, mid: str) -> ModelDetail:
113
+ d = self.peek(mid)
114
+ if d:
115
+ return d
116
+ d = self._fetch(mid)
117
+ with self._lock:
118
+ self._mem[mid] = d
119
+ try:
120
+ self._dir.mkdir(parents=True, exist_ok=True)
121
+ self._path(mid).write_text(json.dumps(asdict(d)))
122
+ except OSError:
123
+ pass
124
+ return d
125
+
126
+ def get_many(self, ids: list[str], timeout: float = 8.0) -> dict[str, ModelDetail]:
127
+ """Fetch several in parallel; whatever isn't back within `timeout` is left out."""
128
+ out = {}
129
+ pending = {}
130
+ for mid in ids:
131
+ d = self.peek(mid)
132
+ if d:
133
+ out[mid] = d
134
+ else:
135
+ pending[mid] = self._pool.submit(self.get, mid)
136
+ deadline = time.time() + timeout
137
+ for mid, fut in pending.items():
138
+ try:
139
+ out[mid] = fut.result(timeout=max(0.0, deadline - time.time()))
140
+ except Exception:
141
+ pass
142
+ return out
app/parsing.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn raw Hugging Face model metadata into the fields the explorer filters on.
2
+
3
+ Every function here takes untrusted, possibly incomplete metadata and returns a
4
+ best guess plus where the guess came from. Nothing raises on junk input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from dataclasses import dataclass, field, asdict
12
+ from typing import Any
13
+
14
+ # Canonical display names for family keywords. This is normalization, not a
15
+ # closed list: a family we have never seen falls through to its leading token.
16
+ FAMILY_ALIASES: dict[str, str] = {
17
+ "qwen": "Qwen", "qwq": "Qwen", "qwen2": "Qwen", "qwen3": "Qwen",
18
+ "gemma": "Gemma", "paligemma": "Gemma", "medgemma": "Gemma",
19
+ "translategemma": "Gemma", "codegemma": "Gemma", "shieldgemma": "Gemma",
20
+ "llama": "Llama", "codellama": "Llama", "tinyllama": "Llama",
21
+ "deepseek": "DeepSeek",
22
+ "mistral": "Mistral", "ministral": "Mistral", "mixtral": "Mistral",
23
+ "devstral": "Mistral", "magistral": "Mistral", "codestral": "Mistral",
24
+ "glm": "GLM", "chatglm": "GLM", "codegeex": "GLM",
25
+ "phi": "Phi",
26
+ "minimax": "MiniMax",
27
+ "kimi": "Kimi",
28
+ "granite": "Granite",
29
+ "falcon": "Falcon",
30
+ "lfm": "LFM",
31
+ "smollm": "SmolLM", "smolvlm": "SmolLM",
32
+ "olmo": "OLMo",
33
+ "nemotron": "Nemotron",
34
+ "minicpm": "MiniCPM",
35
+ "internvl": "InternVL",
36
+ "hunyuan": "Hunyuan",
37
+ "ernie": "ERNIE",
38
+ "gpt": "GPT-OSS",
39
+ "whisper": "Whisper",
40
+ "yi": "Yi",
41
+ "jp": "LLM-jp",
42
+ "exaone": "EXAONE",
43
+ "laguna": "Laguna",
44
+ "longcat": "LongCat",
45
+ "mamba": "Mamba",
46
+ "aya": "Aya",
47
+ "llava": "LLaVA",
48
+ "starcoder": "StarCoder",
49
+ "mimo": "MiMo",
50
+ "ling": "Ling",
51
+ "ring": "Ling",
52
+ }
53
+
54
+ # Keys distinctive enough to match inside a longer word ("ling" would catch "Starling").
55
+ GLUE_SAFE = ["gemma", "llama", "qwen", "olmo", "mistral", "deepseek", "granite", "nemotron", "smolvlm", "minicpm"]
56
+
57
+ # Tokens that are publishers or fine-tune brands rather than families; skip past
58
+ # them and look at the next token (e.g. "Meta-Llama-3", "Josiefied-Qwen3").
59
+ NON_FAMILY_PREFIXES = {
60
+ "meta", "nvidia", "microsoft", "google", "ibm", "apple", "amd", "open",
61
+ "josiefied", "josie", "dolphin", "hermes", "openhermes", "huihui", "abliterated",
62
+ "nous", "the", "mlx", "community", "unsloth", "llm", "ui", "c", "s", "hy",
63
+ # generic words that show up first in fine-tune names
64
+ "instruct", "thinking", "chat", "base", "preview", "mini", "big", "small", "tiny", "vl", "mt", "su",
65
+ "ai", "ii", "agents", "swe", "got", "unlimited", "model", "quantized", "uncensored", "v", "it", "ocr", "bf", "fp", "mlx",
66
+ }
67
+
68
+ LLM_PIPELINES = {"text-generation", "image-text-to-text", "any-to-any"}
69
+
70
+ PARAM_BUCKETS = ["<3B", "3-8B", "8-15B", "15-35B", "35-70B", "70B+"]
71
+
72
+ _TOKEN_SPLIT = re.compile(r"[-_. /]+")
73
+
74
+
75
+ def _tokens(name: str) -> list[str]:
76
+ out = []
77
+ for tok in _TOKEN_SPLIT.split(name.lower()):
78
+ alpha = re.match(r"[a-z]+", tok)
79
+ if alpha:
80
+ out.append(alpha.group(0))
81
+ return out
82
+
83
+
84
+ def base_model_of(tags: list[str] | None) -> str | None:
85
+ """First `base_model:org/name` tag (ignores `base_model:quantized:...` forms)."""
86
+ for t in tags or []:
87
+ if isinstance(t, str) and t.startswith("base_model:") and t.count(":") == 1:
88
+ return t.split(":", 1)[1]
89
+ return None
90
+
91
+
92
+ def detect_family(model_id: str, tags: list[str] | None = None) -> str:
93
+ base = base_model_of(tags)
94
+ candidates = []
95
+ if base:
96
+ candidates.append(base.split("/")[-1])
97
+ candidates.append(model_id.split("/")[-1])
98
+ # Known keyword anywhere in the name wins (catches "Josiefied-Qwen3-8B").
99
+ for name in candidates:
100
+ for tok in _tokens(name):
101
+ if tok in FAMILY_ALIASES:
102
+ return FAMILY_ALIASES[tok]
103
+ # ...or glued onto another word ("FunctionGemma", "OLMoE", "SmolVLM").
104
+ for name in candidates:
105
+ for tok in _tokens(name):
106
+ for key in GLUE_SAFE:
107
+ if tok.startswith(key) or tok.endswith(key):
108
+ return FAMILY_ALIASES[key]
109
+ # Otherwise the first non-publisher token, title-cased.
110
+ for name in candidates:
111
+ for tok in _tokens(name):
112
+ if tok not in NON_FAMILY_PREFIXES and len(tok) >= 2:
113
+ return tok[:1].upper() + tok[1:]
114
+ return "Other"
115
+
116
+
117
+ # ---------------------------------------------------------------- parameters
118
+
119
+ _PARAM_RE = re.compile(r"(?<![a-z0-9])(\d+(?:\.\d+)?)\s*([bm])(?![a-z])", re.I)
120
+ _MOE_ACTIVE_RE = re.compile(r"(?<![a-z0-9])a(\d+(?:\.\d+)?)b(?![a-z])", re.I)
121
+ _EXPERTS_RE = re.compile(r"(?<![a-z0-9])(\d+)x(\d+(?:\.\d+)?)b(?![a-z])", re.I)
122
+
123
+
124
+ @dataclass
125
+ class Params:
126
+ total: float | None # parameters, not billions
127
+ active: float | None
128
+ moe: bool
129
+ source: str # safetensors | name | config | unknown
130
+ name_total: float | None = None # size claimed by the repo name, if any
131
+
132
+ @property
133
+ def partial(self) -> bool:
134
+ """The repo holds far fewer weights than its name claims (draft head, adapter, shard)."""
135
+ return bool(self.source == "safetensors" and self.name_total and self.total
136
+ and self.total < 0.25 * self.name_total)
137
+
138
+
139
+ def _unpacked_total(safetensors: dict, bits: float | None, name_total: float | None = None) -> tuple[float | None, str]:
140
+ """Hub parameter counts for MLX repos come in two styles.
141
+
142
+ Newer indexing reports unpacked weights (Qwen3-8B-4bit: U32=8.19B). Older indexing
143
+ counts packed U32 elements, so a 4-bit 7.8B model shows U32=0.98B plus F16=0.24B of
144
+ group scales/biases. In packed style the float count is about U32/bits (one scale
145
+ and one bias per 64 weights); in unpacked style floats are only norms, embeddings
146
+ or a vision tower, typically under a tenth of U32. When the repo name states a
147
+ size, whichever reading is closer to it wins.
148
+ """
149
+ total = safetensors.get("total")
150
+ if not isinstance(total, (int, float)) or total <= 0:
151
+ return None, "unknown"
152
+ by_dtype = safetensors.get("parameters")
153
+ packed = by_dtype.get("U32") if isinstance(by_dtype, dict) else None
154
+ if not isinstance(packed, (int, float)) or packed <= 0 or not bits or bits >= 16:
155
+ return float(total), "safetensors"
156
+ floats = sum(v for k, v in by_dtype.items() if k != "U32" and isinstance(v, (int, float)))
157
+ quantized = packed * 32 / bits
158
+ unpacked = quantized + max(0.0, floats - quantized / 32) # minus one scale + one bias per 64 weights
159
+ if name_total:
160
+ closer = min((float(total), unpacked), key=lambda t: abs(math.log(t / name_total)))
161
+ return closer, "safetensors"
162
+ looks_packed = floats >= 0.9 * packed / bits
163
+ return (unpacked if looks_packed else float(total)), "safetensors"
164
+
165
+
166
+ def detect_params(model_id: str, safetensors: Any = None, config: dict | None = None,
167
+ bits: float | None = None) -> Params:
168
+ name = model_id.split("/")[-1]
169
+ moe = False
170
+ active = None
171
+ m = _MOE_ACTIVE_RE.search(name)
172
+ if m:
173
+ moe = True
174
+ active = float(m.group(1)) * 1e9
175
+ if _EXPERTS_RE.search(name):
176
+ moe = True
177
+ if isinstance(config, dict):
178
+ cfg = config.get("text_config") if isinstance(config.get("text_config"), dict) else config
179
+ for k in ("num_experts", "num_local_experts", "n_routed_experts", "moe_num_experts"):
180
+ v = cfg.get(k)
181
+ if isinstance(v, (int, float)) and v > 1:
182
+ moe = True
183
+
184
+ name_total = None
185
+ ex = _EXPERTS_RE.search(name)
186
+ if ex:
187
+ # "8x7B" is a rough upper bound; shared weights make the real total smaller.
188
+ name_total = float(ex.group(1)) * float(ex.group(2)) * 1e9
189
+ moe = True
190
+ else:
191
+ sizes = [float(num) * (1e9 if unit.lower() == "b" else 1e6) for num, unit in _PARAM_RE.findall(name)]
192
+ sizes = [x for x in sizes if x <= 3e12] # "2026.27B" is a date next to a size, not a size
193
+ if sizes:
194
+ # The active-param token ("A3B") also matches; the total is the largest.
195
+ name_total = max(sizes)
196
+ if active is not None and name_total == active and len(sizes) == 1:
197
+ name_total = None
198
+
199
+ if isinstance(safetensors, dict):
200
+ t, src = _unpacked_total(safetensors, bits, name_total)
201
+ if t:
202
+ return Params(t, active, moe, src, name_total)
203
+ if name_total:
204
+ return Params(name_total, active, moe, "name", name_total)
205
+ return Params(None, active, moe, "unknown")
206
+
207
+
208
+ def param_bucket(total: float | None) -> str | None:
209
+ if not total:
210
+ return None
211
+ b = total / 1e9
212
+ if b < 3:
213
+ return "<3B"
214
+ if b < 8:
215
+ return "3-8B"
216
+ if b < 15:
217
+ return "8-15B"
218
+ if b < 35:
219
+ return "15-35B"
220
+ if b < 70:
221
+ return "35-70B"
222
+ return "70B+"
223
+
224
+
225
+ # -------------------------------------------------------------- quantization
226
+
227
+ @dataclass
228
+ class Quant:
229
+ bits: float | None # nominal bits for the bulk of the weights (16 = unquantized)
230
+ mode: str | None # affine | mxfp4 | nvfp4 | mxfp8 | dwq | float | None
231
+ mixed: bool
232
+ source: str # tags | config | name | unknown
233
+ label: str = field(default="")
234
+
235
+ def __post_init__(self):
236
+ if not self.label:
237
+ self.label = quant_label(self.bits, self.mixed)
238
+
239
+ def to_dict(self) -> dict:
240
+ return asdict(self)
241
+
242
+
243
+ QUANT_BUCKETS = ["2-bit", "3-bit", "4-bit", "5-bit", "6-bit", "8-bit", "16-bit", "unknown"]
244
+
245
+
246
+ def quant_label(bits: float | None, mixed: bool = False) -> str:
247
+ if bits is None:
248
+ return "unknown"
249
+ if bits >= 16:
250
+ return "16-bit"
251
+ b = int(round(bits))
252
+ label = f"{b}-bit" if b in (2, 3, 4, 5, 6, 8) else ("unknown" if b < 2 else f"{b}-bit")
253
+ return label + (" mixed" if mixed and label != "unknown" else "")
254
+
255
+
256
+ def quant_bucket(q: Quant) -> str:
257
+ return q.label.replace(" mixed", "")
258
+
259
+
260
+ _NAME_BITS = [
261
+ (re.compile(r"(?<![a-z0-9])(?:mx|nv)fp4(?![a-z0-9])", re.I), 4, None),
262
+ (re.compile(r"(?<![a-z0-9])mxfp8(?![a-z0-9])", re.I), 8, "mxfp8"),
263
+ (re.compile(r"(?<![a-z0-9])(?:qnt)?(\d)[-_]?bits?(?![a-z0-9])", re.I), None, None),
264
+ (re.compile(r"(?<![a-z0-9])q(\d)(?![a-z0-9])", re.I), None, None),
265
+ (re.compile(r"(?<![a-z0-9])(?:bf16|fp16|f16|float16|bfloat16)(?![a-z0-9])", re.I), 16, "float"),
266
+ ]
267
+
268
+
269
+ def _bits_from_config(config: dict | None) -> tuple[float | None, str | None, bool]:
270
+ if not isinstance(config, dict):
271
+ return None, None, False
272
+ q = None
273
+ for holder in (config, config.get("text_config") if isinstance(config.get("text_config"), dict) else None):
274
+ if not holder:
275
+ continue
276
+ q = holder.get("quantization") or holder.get("quantization_config")
277
+ if isinstance(q, dict):
278
+ break
279
+ q = None
280
+ if not isinstance(q, dict):
281
+ return None, None, False
282
+ bits = q.get("bits")
283
+ mode = q.get("mode") if isinstance(q.get("mode"), str) else None
284
+ per_layer = [v for v in q.values() if isinstance(v, dict) and "bits" in v]
285
+ layer_bits = {v.get("bits") for v in per_layer if isinstance(v.get("bits"), (int, float))}
286
+ mixed = bool(layer_bits and (len(layer_bits | ({bits} if isinstance(bits, (int, float)) else set())) > 1))
287
+ if not isinstance(bits, (int, float)):
288
+ bits = min(layer_bits) if layer_bits else None
289
+ return (float(bits) if bits else None), (mode or "affine"), mixed
290
+
291
+
292
+ def detect_quant(model_id: str, tags: list[str] | None = None, config: dict | None = None) -> Quant:
293
+ name = model_id.split("/")[-1]
294
+ tags = [t for t in (tags or []) if isinstance(t, str)]
295
+ lname = name.lower()
296
+ name_mode = None
297
+ if "dwq" in lname:
298
+ name_mode = "dwq"
299
+ elif "nvfp4" in lname:
300
+ name_mode = "nvfp4"
301
+ elif "mxfp4" in lname:
302
+ name_mode = "mxfp4"
303
+
304
+ # 1. config.json is the most direct evidence when we have it.
305
+ cbits, cmode, cmixed = _bits_from_config(config)
306
+ if cbits:
307
+ return Quant(cbits, name_mode or cmode, cmixed, "config")
308
+ if isinstance(config, dict) and (config.get("model_type") or config.get("architectures")):
309
+ holders = [config] + [v for v in config.values() if isinstance(v, dict)]
310
+ if not any(k in h for h in holders for k in ("quantization", "quantization_config")):
311
+ # A real MLX config with no quantization block at all means unquantized weights.
312
+ return Quant(16.0, "float", False, "config")
313
+ # A quantization block in a format we don't parse: fall through to tags and name.
314
+
315
+ # 2. Hub tags: "4-bit" is the canonical form; "4bit"/"8bit" pairs signal mixed maps.
316
+ tag_bits = sorted({int(m.group(1)) for t in tags for m in [re.fullmatch(r"(\d)-bit", t)] if m})
317
+ loose_bits = {int(m.group(1)) for t in tags for m in [re.fullmatch(r"(\d)bit", t)] if m}
318
+ if tag_bits:
319
+ bits = tag_bits[0]
320
+ mixed = len(set(tag_bits) | loose_bits) > 1
321
+ return Quant(float(bits), name_mode or "affine", mixed, "tags")
322
+
323
+ # 3. Name conventions.
324
+ for rx, fixed, mode in _NAME_BITS:
325
+ m = rx.search(name)
326
+ if m:
327
+ bits = fixed if fixed is not None else int(m.group(1))
328
+ if bits in (2, 3, 4, 5, 6, 8, 16):
329
+ return Quant(float(bits), name_mode or mode or ("float" if bits == 16 else "affine"), False, "name")
330
+ if loose_bits:
331
+ b = sorted(loose_bits)
332
+ return Quant(float(b[0]), name_mode or "affine", len(b) > 1, "tags")
333
+ return Quant(None, name_mode, False, "unknown")
334
+
335
+
336
+ # ------------------------------------------------------------ architecture
337
+
338
+ @dataclass
339
+ class Arch:
340
+ layers: int | None
341
+ full_attention_layers: int | None
342
+ sliding_layers: int
343
+ kv_heads: int | None
344
+ head_dim: int | None
345
+ sliding_window: int | None
346
+ max_context: int | None
347
+ known: bool
348
+
349
+
350
+ def _int(v: Any) -> int | None:
351
+ if isinstance(v, bool):
352
+ return None
353
+ if isinstance(v, (int, float)) and v > 0:
354
+ return int(v)
355
+ return None
356
+
357
+
358
+ def detect_arch(config: dict | None) -> Arch:
359
+ """Pull the fields a KV-cache estimate needs. Missing -> known=False."""
360
+ if not isinstance(config, dict):
361
+ return Arch(None, None, 0, None, None, None, None, False)
362
+ cfg = config.get("text_config") if isinstance(config.get("text_config"), dict) else config
363
+ layers = _int(cfg.get("num_hidden_layers") or cfg.get("n_layer") or cfg.get("num_layers"))
364
+ heads = _int(cfg.get("num_attention_heads") or cfg.get("n_head"))
365
+ kv = _int(cfg.get("num_key_value_heads") or cfg.get("n_head_kv")) or heads
366
+ hidden = _int(cfg.get("hidden_size") or cfg.get("n_embd"))
367
+ head_dim = _int(cfg.get("head_dim")) or (hidden // heads if hidden and heads else None)
368
+ # MLA (DeepSeek-style) caches a compressed latent instead of full K/V.
369
+ kv_lora = _int(cfg.get("kv_lora_rank"))
370
+ if kv_lora:
371
+ rope_dim = _int(cfg.get("qk_rope_head_dim")) or 0
372
+ kv, head_dim = 1, (kv_lora + rope_dim) // 2 or None
373
+ sliding = _int(cfg.get("sliding_window")) if cfg.get("use_sliding_window", True) is not False else None
374
+ max_ctx = _int(cfg.get("max_position_embeddings") or cfg.get("max_seq_len") or cfg.get("seq_length"))
375
+
376
+ full = layers
377
+ sliding_layers = 0
378
+ layer_types = cfg.get("layer_types")
379
+ if isinstance(layer_types, list) and layer_types and all(isinstance(t, str) for t in layer_types):
380
+ full = sum(1 for t in layer_types if t in ("full_attention", "attention", "global_attention"))
381
+ sliding_layers = sum(1 for t in layer_types if "sliding" in t)
382
+ if not sliding_layers:
383
+ sliding = None
384
+ else:
385
+ interval = _int(cfg.get("full_attention_interval"))
386
+ if layers and interval:
387
+ full = max(1, layers // interval) # the rest are linear-attention layers
388
+ sliding = None
389
+ elif sliding and layers and _int(cfg.get("sliding_window_pattern")):
390
+ # Gemma-style: one global layer every `pattern` layers.
391
+ full = max(1, layers // _int(cfg.get("sliding_window_pattern")))
392
+ sliding_layers = layers - full
393
+ # A bare sliding_window with no layer map: keep every layer as full
394
+ # attention so the estimate stays an upper bound.
395
+ if not sliding:
396
+ sliding_layers = 0
397
+ known = bool(layers and kv and head_dim)
398
+ return Arch(layers, full, sliding_layers, kv, head_dim, sliding, max_ctx, known)
app/ratelimit.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory token bucket.
2
+
3
+ Clients are keyed by a salted hash of their address. The salt is random, lives
4
+ only in this process, and rotates hourly, so the key can't be reversed, joined
5
+ across restarts, or stored. Nothing here is logged or persisted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import secrets
12
+ import threading
13
+ import time
14
+
15
+
16
+ class RateLimiter:
17
+ def __init__(self, rate_per_minute: float = 60, burst: int = 30, rotate_seconds: float = 3600):
18
+ self.rate = rate_per_minute / 60.0
19
+ self.burst = burst
20
+ self.rotate_seconds = rotate_seconds
21
+ self._salt = secrets.token_bytes(16)
22
+ self._salt_at = time.time()
23
+ self._buckets: dict[str, tuple[float, float]] = {}
24
+ self._lock = threading.Lock()
25
+
26
+ def _key(self, client: str) -> str:
27
+ if time.time() - self._salt_at > self.rotate_seconds:
28
+ self._salt = secrets.token_bytes(16)
29
+ self._salt_at = time.time()
30
+ self._buckets.clear()
31
+ return hashlib.blake2b(client.encode(), key=self._salt, digest_size=12).hexdigest()
32
+
33
+ def allow(self, client: str, cost: float = 1.0) -> bool:
34
+ now = time.time()
35
+ with self._lock:
36
+ key = self._key(client)
37
+ tokens, last = self._buckets.get(key, (float(self.burst), now))
38
+ tokens = min(self.burst, tokens + (now - last) * self.rate)
39
+ if tokens < cost:
40
+ self._buckets[key] = (tokens, now)
41
+ return False
42
+ self._buckets[key] = (tokens - cost, now)
43
+ if len(self._buckets) > 50_000:
44
+ self._buckets.clear()
45
+ return True
app/recommend.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recommendation scoring.
2
+
3
+ The engine only looks at measurable properties: estimated memory fit, bit width,
4
+ context, size, Hub popularity, recency and community data. It never looks at a
5
+ model's name, uploader or brand. `tests/test_recommend.py` enforces that.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from typing import Protocol
14
+
15
+ from .catalogue import ModelRecord
16
+ from .memory import MemoryEstimate, estimate
17
+ from .parsing import Arch, detect_arch, detect_quant
18
+ from .model_detail import ModelDetail
19
+
20
+ PRIORITIES = ["balanced", "quality", "speed", "memory", "long_context"]
21
+
22
+ # How much each priority cares about each component. Rows sum to 1.
23
+ WEIGHTS: dict[str, dict[str, float]] = {
24
+ "balanced": {"fit": 0.30, "quant": 0.15, "context": 0.10, "capacity": 0.15, "popularity": 0.20, "recency": 0.05, "community": 0.05},
25
+ "quality": {"fit": 0.25, "quant": 0.25, "context": 0.05, "capacity": 0.25, "popularity": 0.12, "recency": 0.03, "community": 0.05},
26
+ "speed": {"fit": 0.35, "quant": 0.25, "context": 0.05, "capacity": 0.00, "popularity": 0.15, "recency": 0.05, "community": 0.15},
27
+ "memory": {"fit": 0.35, "quant": 0.35, "context": 0.05, "capacity": 0.00, "popularity": 0.15, "recency": 0.05, "community": 0.05},
28
+ "long_context": {"fit": 0.25, "quant": 0.15, "context": 0.30, "capacity": 0.05, "popularity": 0.15, "recency": 0.05, "community": 0.05},
29
+ }
30
+
31
+ # Preference over nominal bit width per priority (1 = most preferred).
32
+ QUANT_PREF: dict[str, dict[int, float]] = {
33
+ "quality": {16: 1.0, 8: 0.95, 6: 0.85, 5: 0.75, 4: 0.6, 3: 0.35, 2: 0.15},
34
+ "balanced": {4: 1.0, 5: 0.95, 6: 0.85, 8: 0.75, 3: 0.55, 16: 0.45, 2: 0.25},
35
+ "speed": {4: 1.0, 3: 0.85, 5: 0.85, 6: 0.7, 2: 0.6, 8: 0.55, 16: 0.25},
36
+ "memory": {2: 1.0, 3: 0.9, 4: 0.8, 5: 0.6, 6: 0.5, 8: 0.3, 16: 0.1},
37
+ "long_context": {4: 1.0, 5: 0.9, 6: 0.8, 3: 0.7, 8: 0.65, 2: 0.4, 16: 0.3},
38
+ }
39
+
40
+ def fit_score(ratio: float) -> float:
41
+ """Continuous version of the fit classes, so 'a bit too big' beats 'far too big'."""
42
+ if ratio < 0.70:
43
+ return 1.0
44
+ if ratio < 0.85:
45
+ return 1.0 - (ratio - 0.70) / 0.15 * 0.2
46
+ if ratio < 1.0:
47
+ return 0.8 - (ratio - 0.85) / 0.15 * 0.4
48
+ return max(0.0, 0.4 * (1 - (ratio - 1.0)))
49
+
50
+
51
+ @dataclass
52
+ class Query:
53
+ family: str | None = None
54
+ size: str | None = None
55
+ quant: str | None = None
56
+ context: int = 8192
57
+ ram_gb: float | None = None
58
+ ram_source: str | None = None # confirmed | estimated | None
59
+ priority: str = "balanced"
60
+
61
+
62
+ @dataclass
63
+ class CommunitySignal:
64
+ feedback_count: int = 0
65
+ positive_share: float | None = None
66
+ benchmark_count: int = 0
67
+ median_generation_tps: float | None = None
68
+
69
+
70
+ @dataclass
71
+ class Scored:
72
+ model: ModelRecord
73
+ score: float
74
+ components: dict[str, float]
75
+ reasons: list[str]
76
+ memory: MemoryEstimate
77
+ detail_loaded: bool
78
+ community: CommunitySignal = field(default_factory=CommunitySignal)
79
+ quant: dict = field(default_factory=dict) # effective quantization (config beats name/tags)
80
+
81
+ def to_dict(self) -> dict:
82
+ return {
83
+ "model": self.model.public(),
84
+ "score": round(self.score, 1),
85
+ "components": {k: round(v, 3) for k, v in self.components.items()},
86
+ "reasons": self.reasons,
87
+ "memory": self.memory.to_dict(),
88
+ "detail_loaded": self.detail_loaded,
89
+ "quant": self.quant or self.model.quant,
90
+ "community": self.community.__dict__,
91
+ }
92
+
93
+
94
+ class RecommendationEngine(Protocol):
95
+ version: str
96
+
97
+ def score(self, models: list[ModelRecord], query: Query,
98
+ details: dict[str, ModelDetail], community: dict[str, CommunitySignal]) -> list[Scored]: ...
99
+
100
+
101
+ def _months_since(iso: str | None, now: datetime) -> float | None:
102
+ if not iso:
103
+ return None
104
+ try:
105
+ dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
106
+ except ValueError:
107
+ return None
108
+ return max(0.0, (now - dt).days / 30.4)
109
+
110
+
111
+ def _fmt_ctx(n: int) -> str:
112
+ return f"{n // 1024}k" if n >= 1024 else str(n)
113
+
114
+
115
+ def _fmt_downloads(n: int) -> str:
116
+ if n >= 1_000_000:
117
+ return f"{n / 1e6:.1f}M"
118
+ if n >= 1000:
119
+ return f"{n / 1e3:.0f}k"
120
+ return str(n)
121
+
122
+
123
+ class HeuristicEngine:
124
+ version = "heuristic-v1"
125
+
126
+ def __init__(self, now: datetime | None = None):
127
+ self._now = now
128
+
129
+ def memory_for(self, m: ModelRecord, query: Query, detail: ModelDetail | None) -> tuple[MemoryEstimate, dict]:
130
+ quant = m.quant
131
+ arch = Arch(None, None, 0, None, None, None, None, False)
132
+ file_bytes = None
133
+ if detail:
134
+ arch = detect_arch(detail.config)
135
+ file_bytes = detail.weight_bytes
136
+ if detail.config:
137
+ q = detect_quant(m.id, m.tags, detail.config)
138
+ if q.bits is not None:
139
+ quant = q.to_dict()
140
+ mem = estimate(params=m.params, bits=quant["bits"], mode=quant["mode"], file_bytes=file_bytes,
141
+ arch=arch, context=query.context, ram_gb=query.ram_gb)
142
+ return mem, quant
143
+
144
+ def norms(self, models, query, details=None) -> dict:
145
+ """Set-wide reference values, computed once over ALL matches so a model's score
146
+ doesn't depend on which page of results it is rescored in."""
147
+ details = details or {}
148
+ fitting = []
149
+ for m in models:
150
+ if not m.params or m.partial:
151
+ continue
152
+ if query.ram_gb is None:
153
+ fitting.append(m.params)
154
+ continue
155
+ mem, _ = self.memory_for(m, query, details.get(m.id))
156
+ if mem.fit in ("Comfortable", "Likely"):
157
+ fitting.append(m.params)
158
+ return {
159
+ "max_downloads": max((m.downloads for m in models), default=0),
160
+ "max_likes": max((m.likes for m in models), default=0),
161
+ "max_fitting_params": max(fitting, default=0),
162
+ }
163
+
164
+ def score(self, models, query, details=None, community=None, norms=None) -> list[Scored]:
165
+ details = details or {}
166
+ community = community or {}
167
+ now = self._now or datetime.now(timezone.utc)
168
+ priority = query.priority if query.priority in WEIGHTS else "balanced"
169
+ weights = WEIGHTS[priority]
170
+ norms = norms or self.norms(models, query, details)
171
+ max_dl, max_likes = norms["max_downloads"], norms["max_likes"]
172
+ max_fit_params = norms["max_fitting_params"]
173
+ out = []
174
+ for m in models:
175
+ detail = details.get(m.id)
176
+ mem, quant = self.memory_for(m, query, detail)
177
+ comm = community.get(m.id) or CommunitySignal()
178
+ comp: dict[str, float] = {}
179
+ reasons: list[str] = []
180
+
181
+ # Hardware fit
182
+ if mem.total_gb is None:
183
+ comp["fit"] = 0.5
184
+ reasons.append("Memory need unknown: some model information isn't available.")
185
+ elif query.ram_gb is None:
186
+ comp["fit"] = 0.6
187
+ reasons.append(f"Needs roughly {mem.total_gb:.1f} GB at {_fmt_ctx(query.context)} context (estimate). "
188
+ "Set your memory to check fit.")
189
+ else:
190
+ comp["fit"] = fit_score(mem.ratio)
191
+ src = "confirmed" if query.ram_source == "confirmed" else "estimated"
192
+ reasons.append(f"{mem.fit} fit: ~{mem.total_gb:.1f} GB needed vs ~{mem.usable_gb:.0f} GB "
193
+ f"GPU-usable on a {query.ram_gb:g} GB Mac ({src}).")
194
+
195
+ # Quantization vs priority
196
+ bits = quant["bits"]
197
+ if bits is None:
198
+ comp["quant"] = 0.4
199
+ reasons.append("Quantization unknown.")
200
+ else:
201
+ key = 16 if bits >= 16 else int(round(bits))
202
+ comp["quant"] = QUANT_PREF[priority].get(key, 0.4)
203
+ if query.ram_gb and mem.fit == "Unlikely":
204
+ comp["quant"] *= 0.25 # bit-width preference is moot if it won't load
205
+ label = quant.get("label") or f"{key}-bit"
206
+ if quant is not m.quant and m.quant.get("bits") and quant.get("bits") != m.quant.get("bits"):
207
+ reasons.append(f"Its config.json says {label}, although the name or tags say {m.quant['label']}.")
208
+ note = {
209
+ "quality": "more bits keep more of the original model's quality",
210
+ "memory": "fewer bits mean a smaller download and less memory",
211
+ "speed": "fewer bits usually decode faster on unified memory, but speed depends on the model and Mac",
212
+ "long_context": "leaves more memory for the KV cache",
213
+ "balanced": "4 to 6 bits is the usual quality/memory middle ground",
214
+ }[priority]
215
+ reasons.append(f"{label}: {note}.")
216
+
217
+ # Context
218
+ if mem.exceeds_model_context:
219
+ comp["context"] = 0.0
220
+ reasons.append(f"The model's config lists a shorter maximum context than {_fmt_ctx(query.context)}.")
221
+ elif mem.total_gb is not None and mem.kv_gb is not None:
222
+ if query.ram_gb:
223
+ headroom = 1 - mem.total_gb / max(mem.usable_gb or 1, 0.1)
224
+ comp["context"] = max(0.0, min(1.0, 0.5 + headroom))
225
+ else:
226
+ share = mem.kv_gb / max(mem.total_gb, 0.01)
227
+ comp["context"] = max(0.0, 1 - share)
228
+ bound = " (rough, architecture unknown)" if mem.kv_rough else ""
229
+ reasons.append(f"KV cache at {_fmt_ctx(query.context)}: ~{mem.kv_gb:.1f} GB{bound}.")
230
+ else:
231
+ comp["context"] = 0.5
232
+
233
+ # Capacity: among models that fit, more parameters usually means a more capable model
234
+ if query.ram_gb is None:
235
+ comp["capacity"] = 0.5 # without a memory budget, "bigger" would just mean "won't fit"
236
+ elif m.params and max_fit_params and mem.fit not in ("Borderline", "Unlikely"):
237
+ comp["capacity"] = min(1.0, math.log10(1 + m.params / 1e8) / math.log10(1 + max_fit_params / 1e8))
238
+ if weights["capacity"] and m.params < 0.5 * max_fit_params:
239
+ reasons.append(f"{m.params / 1e9:.1f}B parameters; models up to {max_fit_params / 1e9:.0f}B "
240
+ f"in these results fit your Mac, and larger models are usually more capable.")
241
+ elif m.params and max_fit_params:
242
+ comp["capacity"] = 0.0
243
+ else:
244
+ comp["capacity"] = 0.5
245
+
246
+ # Popularity: downloads and likes, log-scaled against the best in this result set
247
+ dl = math.log10(m.downloads + 1) / math.log10(max_dl + 1) if max_dl > 0 else 0.0
248
+ lk = math.log10(m.likes + 1) / math.log10(max_likes + 1) if max_likes > 0 else 0.0
249
+ comp["popularity"] = 0.7 * dl + 0.3 * lk
250
+ if m.downloads:
251
+ reasons.append(f"{_fmt_downloads(m.downloads)} Hugging Face downloads.")
252
+
253
+ # Recency: half-life of a year
254
+ months = _months_since(m.modified, now)
255
+ comp["recency"] = 0.5 ** (months / 12) if months is not None else 0.3
256
+
257
+ # Community data (zero until people contribute)
258
+ if comm.benchmark_count or comm.feedback_count:
259
+ c = min(1.0, math.log10(1 + comm.benchmark_count + comm.feedback_count) / 2)
260
+ if comm.positive_share is not None:
261
+ c = 0.5 * c + 0.5 * comm.positive_share
262
+ comp["community"] = c
263
+ bits_ = []
264
+ if comm.benchmark_count:
265
+ tps = f", median {comm.median_generation_tps:.0f} tok/s" if comm.median_generation_tps else ""
266
+ bits_.append(f"{comm.benchmark_count} MLX benchmark(s){tps}")
267
+ if comm.feedback_count:
268
+ bits_.append(f"{comm.feedback_count} community report(s)")
269
+ reasons.append("Community: " + "; ".join(bits_) + ".")
270
+ else:
271
+ comp["community"] = 0.0
272
+
273
+ score = 100 * sum(weights[k] * comp[k] for k in weights)
274
+ if m.partial:
275
+ score *= 0.6
276
+ reasons.insert(0, "This repo holds far fewer weights than its name suggests. It may be an add-on "
277
+ "(draft head, adapter or partial upload) rather than a complete model.")
278
+ if query.ram_gb and mem.fit == "Unlikely":
279
+ score *= 0.5 # never recommend something that likely won't load over something that will
280
+ out.append(Scored(m, score, comp, reasons, mem, detail is not None and detail.config is not None, comm, quant))
281
+ return out
282
+
283
+
284
+ def sort_results(scored: list[Scored], sort: str) -> list[Scored]:
285
+ if sort == "popular":
286
+ key = lambda s: (-s.model.downloads, s.model.id)
287
+ elif sort == "recent":
288
+ key = lambda s: (-(_ts(s.model.modified)), s.model.id)
289
+ elif sort == "community":
290
+ key = lambda s: (-(s.community.benchmark_count + s.community.feedback_count), -s.score, s.model.id)
291
+ elif sort == "all":
292
+ key = lambda s: s.model.id.lower()
293
+ else:
294
+ key = lambda s: (-s.score, -s.model.downloads, s.model.id)
295
+ return sorted(scored, key=key)
296
+
297
+
298
+ def _ts(iso: str | None) -> float:
299
+ if not iso:
300
+ return 0.0
301
+ try:
302
+ return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
303
+ except ValueError:
304
+ return 0.0
app/sink.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Append-only event storage.
2
+
3
+ Rows are buffered in memory and mirrored to a local spool file. On each flush the
4
+ buffer becomes ONE new Parquet shard (one Hub commit), so a busy day costs at most
5
+ ~144 commits at the default 10-minute interval. Existing shards are never
6
+ rewritten. If an upload fails, the rows stay spooled and go out with the next flush.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ import pyarrow as pa
22
+ import pyarrow.parquet as pq
23
+
24
+ from .events import COLUMNS
25
+
26
+ log = logging.getLogger("explorer.sink")
27
+
28
+ _PA = {"string": pa.string(), "int": pa.int64(), "float": pa.float64(), "bool": pa.bool_(),
29
+ "list": pa.list_(pa.string())}
30
+ SCHEMA = pa.schema([(k, _PA[t]) for k, t in COLUMNS.items()])
31
+ MAX_BUFFER = 100_000
32
+
33
+
34
+ def rows_to_parquet_bytes(rows: list[dict]) -> bytes:
35
+ cols = {k: [r.get(k) for r in rows] for k in COLUMNS}
36
+ table = pa.Table.from_pydict(cols, schema=SCHEMA)
37
+ buf = io.BytesIO()
38
+ pq.write_table(table, buf, compression="zstd")
39
+ return buf.getvalue()
40
+
41
+
42
+ def shard_path(prefix: str, instance: str, when: datetime | None = None) -> str:
43
+ when = when or datetime.now(timezone.utc)
44
+ return f"{prefix.strip('/')}/{when:%Y/%m/%d}/{instance}-{when:%Y%m%dT%H%M%S}-{uuid.uuid4().hex[:6]}.parquet"
45
+
46
+
47
+ class EventSink:
48
+ kind = "base"
49
+
50
+ def __init__(self, spool_dir: Path, flush_seconds: float = 600, prefix: str = "data/events"):
51
+ self.prefix = prefix
52
+ self.flush_seconds = flush_seconds
53
+ self.instance = uuid.uuid4().hex[:8]
54
+ self._spool = spool_dir / f"spool-{self.kind}.jsonl"
55
+ self._buf: list[dict] = []
56
+ self._lock = threading.Lock()
57
+ self._flush_lock = threading.Lock()
58
+ self.dropped = 0
59
+ self.written_rows = 0
60
+ self.written_shards = 0
61
+ self.last_flush_at: float | None = None
62
+ self.last_error: str | None = None
63
+ self.consecutive_failures = 0
64
+ self.all_rows: list[dict] = [] # everything accepted this process (for live stats)
65
+ spool_dir.mkdir(parents=True, exist_ok=True)
66
+ self._recover_spool()
67
+
68
+ def _recover_spool(self):
69
+ try:
70
+ with open(self._spool) as f:
71
+ for line in f:
72
+ try:
73
+ self._buf.append(json.loads(line))
74
+ except ValueError:
75
+ continue
76
+ if self._buf:
77
+ log.info("recovered %d spooled rows", len(self._buf))
78
+ except FileNotFoundError:
79
+ pass
80
+
81
+ def add(self, rows: list[dict]) -> None:
82
+ with self._lock:
83
+ overflow = len(self._buf) + len(rows) - MAX_BUFFER
84
+ if overflow > 0:
85
+ self._buf = self._buf[overflow:]
86
+ self.dropped += overflow
87
+ self._buf.extend(rows)
88
+ self.all_rows.extend(rows)
89
+ if len(self.all_rows) > MAX_BUFFER:
90
+ self.all_rows = self.all_rows[-MAX_BUFFER:]
91
+ try:
92
+ with open(self._spool, "a") as f:
93
+ for r in rows:
94
+ f.write(json.dumps(r) + "\n")
95
+ except OSError:
96
+ log.warning("spool write failed", exc_info=True)
97
+
98
+ @property
99
+ def pending(self) -> int:
100
+ return len(self._buf)
101
+
102
+ def flush(self) -> bool:
103
+ """Write everything buffered as one shard. Returns True if nothing is left pending."""
104
+ with self._flush_lock:
105
+ with self._lock:
106
+ rows = list(self._buf)
107
+ if not rows:
108
+ return True
109
+ path = shard_path(self.prefix, self.instance)
110
+ try:
111
+ self._write_shard(path, rows_to_parquet_bytes(rows))
112
+ except Exception as e:
113
+ self.consecutive_failures += 1
114
+ self.last_error = f"{type(e).__name__}: {str(e)[:200]}"
115
+ log.warning("flush failed (%d pending): %s", len(rows), self.last_error)
116
+ return False
117
+ with self._lock:
118
+ self._buf = self._buf[len(rows):]
119
+ self._rewrite_spool()
120
+ self.written_rows += len(rows)
121
+ self.written_shards += 1
122
+ self.last_flush_at = time.time()
123
+ self.last_error = None
124
+ self.consecutive_failures = 0
125
+ return not self._buf
126
+
127
+ def _rewrite_spool(self):
128
+ try:
129
+ tmp = self._spool.with_suffix(".tmp")
130
+ with open(tmp, "w") as f:
131
+ for r in self._buf:
132
+ f.write(json.dumps(r) + "\n")
133
+ tmp.replace(self._spool)
134
+ except OSError:
135
+ log.warning("spool rewrite failed", exc_info=True)
136
+
137
+ def _write_shard(self, path: str, data: bytes) -> None:
138
+ raise NotImplementedError
139
+
140
+ def read_existing(self) -> list[dict]:
141
+ return []
142
+
143
+ def start(self) -> threading.Thread:
144
+ def loop():
145
+ while True:
146
+ # back off when the Hub is failing: 1x, 2x, 4x... capped at 1h
147
+ wait = min(self.flush_seconds * (2 ** min(self.consecutive_failures, 3)), 3600)
148
+ time.sleep(wait)
149
+ self.flush()
150
+
151
+ t = threading.Thread(target=loop, daemon=True, name=f"sink-{self.kind}")
152
+ t.start()
153
+ return t
154
+
155
+ def status(self) -> dict:
156
+ return {
157
+ "kind": self.kind,
158
+ "pending": self.pending,
159
+ "written_rows": self.written_rows,
160
+ "written_shards": self.written_shards,
161
+ "dropped": self.dropped,
162
+ "last_flush_at": self.last_flush_at,
163
+ "last_error": self.last_error,
164
+ "healthy": self.consecutive_failures == 0,
165
+ }
166
+
167
+
168
+ class LocalParquetSink(EventSink):
169
+ kind = "local"
170
+
171
+ def __init__(self, root: Path, **kw):
172
+ self.root = root
173
+ super().__init__(spool_dir=root / "_spool", **kw)
174
+
175
+ def _write_shard(self, path: str, data: bytes) -> None:
176
+ target = self.root / path
177
+ target.parent.mkdir(parents=True, exist_ok=True)
178
+ if target.exists():
179
+ raise FileExistsError(path)
180
+ tmp = target.with_suffix(".tmp")
181
+ tmp.write_bytes(data)
182
+ tmp.replace(target)
183
+
184
+ def read_existing(self) -> list[dict]:
185
+ rows = []
186
+ for p in sorted((self.root / self.prefix).rglob("*.parquet")):
187
+ try:
188
+ rows.extend(pq.read_table(p).to_pylist())
189
+ except Exception:
190
+ log.warning("unreadable shard %s", p)
191
+ return rows
192
+
193
+
194
+ class HubParquetSink(EventSink):
195
+ kind = "hub"
196
+
197
+ def __init__(self, repo_id: str, token: str | None, spool_dir: Path, api=None, **kw):
198
+ from huggingface_hub import HfApi
199
+
200
+ self.repo_id = repo_id
201
+ self.api = api or HfApi(token=token)
202
+ super().__init__(spool_dir=spool_dir, **kw)
203
+
204
+ def _write_shard(self, path: str, data: bytes) -> None:
205
+ from huggingface_hub import CommitOperationAdd
206
+
207
+ self.api.create_commit(
208
+ repo_id=self.repo_id,
209
+ repo_type="dataset",
210
+ operations=[CommitOperationAdd(path_in_repo=path, path_or_fileobj=data)],
211
+ commit_message=f"Add {path.rsplit('/', 1)[-1]}",
212
+ )
213
+
214
+ def read_existing(self) -> list[dict]:
215
+ from huggingface_hub import hf_hub_download
216
+
217
+ rows = []
218
+ try:
219
+ files = [f for f in self.api.list_repo_files(self.repo_id, repo_type="dataset")
220
+ if f.startswith(self.prefix.strip("/") + "/") and f.endswith(".parquet")]
221
+ except Exception as e:
222
+ self.last_error = f"read: {type(e).__name__}"
223
+ return rows
224
+ for f in files:
225
+ try:
226
+ local = hf_hub_download(self.repo_id, f, repo_type="dataset", token=self.api.token)
227
+ rows.extend(pq.read_table(local).to_pylist())
228
+ except Exception:
229
+ log.warning("unreadable shard %s", f)
230
+ return rows
231
+
232
+
233
+ class NullSink(EventSink):
234
+ """Used when collection is disabled server-side: accepts nothing, stores nothing."""
235
+
236
+ kind = "null"
237
+
238
+ def add(self, rows):
239
+ pass
240
+
241
+ def _write_shard(self, path, data):
242
+ pass
243
+
244
+
245
+ def make_sink(base_dir: Path) -> EventSink:
246
+ mode = os.environ.get("EXPLORER_SINK", "local")
247
+ flush = float(os.environ.get("EXPLORER_FLUSH_SECONDS", "600"))
248
+ prefix = os.environ.get("EXPLORER_DATA_PREFIX", "data/events")
249
+ if mode == "hub":
250
+ repo = os.environ.get("DATASET_REPO", "mlx-community/mlx-model-explorer-data")
251
+ return HubParquetSink(repo, os.environ.get("HF_TOKEN"), spool_dir=base_dir / "_spool",
252
+ flush_seconds=flush, prefix=prefix)
253
+ if mode == "off":
254
+ return NullSink(spool_dir=base_dir / "_spool_null", flush_seconds=flush, prefix=prefix)
255
+ return LocalParquetSink(base_dir / "local_dataset", flush_seconds=flush, prefix=prefix)
app/stats.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aggregate statistics. Only counts and shares leave this module, never rows.
2
+
3
+ Any bucket seen fewer than K_MIN times is folded into "other", so a rare
4
+ configuration can't be traced back to one visitor.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import statistics
10
+ import threading
11
+ import time
12
+ from collections import Counter, defaultdict
13
+ from typing import Callable, Iterable
14
+
15
+ from .recommend import CommunitySignal
16
+
17
+ K_MIN = 5
18
+
19
+
20
+ def _suppress(counter: Counter, k: int = K_MIN, top: int = 15) -> list[dict]:
21
+ total = sum(counter.values())
22
+ items, other = [], 0
23
+ for key, n in counter.most_common():
24
+ if key is None or n < k or len(items) >= top:
25
+ other += n
26
+ else:
27
+ items.append({"key": key, "count": n, "share": round(n / total, 4) if total else 0})
28
+ if other:
29
+ items.append({"key": "other", "count": other, "share": round(other / total, 4) if total else 0})
30
+ return items
31
+
32
+
33
+ def compute(rows: Iterable[dict], k: int = K_MIN) -> dict:
34
+ rows = [r for r in rows if not r.get("suspicious_flags")]
35
+ by_type = Counter(r.get("event_type") for r in rows)
36
+ sessions = len({r.get("session_id") for r in rows if r.get("session_id")})
37
+ # A "query" is a distinct configuration a session looked at (explicit search, or re-running
38
+ # it after changing sort/hardware). Counting each once keeps re-renders from inflating shares.
39
+ seen, searches = set(), []
40
+ for r in rows:
41
+ if r.get("event_type") in ("search", "filter"):
42
+ key = (r.get("session_id") or id(r), r.get("model_family"), r.get("parameter_bucket"),
43
+ r.get("quantization"), r.get("target_context"), r.get("priority"), r.get("hardware_memory_class"))
44
+ if key not in seen:
45
+ seen.add(key)
46
+ searches.append(r)
47
+
48
+ quant = Counter(r.get("quantization") for r in searches if r.get("quantization"))
49
+ qtotal = sum(quant.values())
50
+ share = lambda b: round(quant.get(b, 0) / qtotal, 4) if qtotal else None
51
+ contexts = [r["target_context"] for r in searches if r.get("target_context")]
52
+
53
+ hw_seen, hw = set(), []
54
+ for r in rows: # one count per session and memory class
55
+ if r.get("hardware_memory_class"):
56
+ key = (r.get("session_id") or id(r), r["hardware_memory_class"])
57
+ if key not in hw_seen:
58
+ hw_seen.add(key)
59
+ hw.append(r)
60
+ return {
61
+ "k_min": k,
62
+ "totals": {
63
+ "events": len(rows),
64
+ "sessions": sessions,
65
+ "searches": by_type.get("search", 0),
66
+ "distinct_queries": len(searches),
67
+ "model_views": by_type.get("model_view", 0),
68
+ "model_clicks": by_type.get("model_click", 0),
69
+ "hardware_tests": by_type.get("hardware_test", 0),
70
+ "browser_benchmarks": by_type.get("browser_benchmark", 0),
71
+ "feedback": by_type.get("feedback", 0),
72
+ "mlx_benchmarks": by_type.get("mlx_benchmark_submission", 0),
73
+ },
74
+ "quant_share": {"2-bit": share("2-bit"), "3-bit": share("3-bit"), "4-bit": share("4-bit"),
75
+ "5-bit": share("5-bit"), "6-bit": share("6-bit"), "8-bit": share("8-bit")},
76
+ "average_target_context": round(statistics.mean(contexts)) if len(contexts) >= k else None,
77
+ "families": _suppress(Counter(r.get("model_family") for r in searches), k),
78
+ "quantizations": _suppress(quant, k),
79
+ "sizes": _suppress(Counter(r.get("parameter_bucket") for r in searches), k),
80
+ "ram_classes": _suppress(Counter(r.get("hardware_memory_class") for r in hw), k),
81
+ "contexts": _suppress(Counter(r.get("target_context") for r in searches), k),
82
+ "priorities": _suppress(Counter(r.get("priority") for r in searches), k),
83
+ "selected_models": _suppress(Counter(r.get("selected_model") for r in rows
84
+ if r.get("event_type") in ("model_select", "model_click")), k),
85
+ "gpu_capability": _suppress(Counter(r.get("gpu_capability_class") for r in rows
86
+ if r.get("event_type") == "hardware_test"), k),
87
+ "webgpu_available": _suppress(Counter(r.get("webgpu_available") for r in rows
88
+ if r.get("event_type") == "hardware_test"), k),
89
+ }
90
+
91
+
92
+ def community_signals(rows: Iterable[dict]) -> dict[str, CommunitySignal]:
93
+ fb: dict[str, list[dict]] = defaultdict(list)
94
+ bench: dict[str, list[float]] = defaultdict(list)
95
+ for r in rows:
96
+ if r.get("suspicious_flags"):
97
+ continue
98
+ m = r.get("selected_model")
99
+ if not m:
100
+ continue
101
+ if r.get("event_type") == "feedback" and r.get("tried") == "yes":
102
+ fb[m].append(r)
103
+ elif r.get("event_type") == "mlx_benchmark_submission" and r.get("generation_tps"):
104
+ bench[m].append(float(r["generation_tps"]))
105
+ out = {}
106
+ for m in set(fb) | set(bench):
107
+ f = fb.get(m, [])
108
+ rated = [x for x in f if x.get("outcome") in ("worked", "problem")]
109
+ pos = sum(1 for x in rated if x.get("outcome") == "worked")
110
+ b = bench.get(m, [])
111
+ out[m] = CommunitySignal(
112
+ feedback_count=len(f),
113
+ positive_share=round(pos / len(rated), 3) if rated else None,
114
+ benchmark_count=len(b),
115
+ median_generation_tps=round(statistics.median(b), 1) if b else None,
116
+ )
117
+ return out
118
+
119
+
120
+ class StatsCache:
121
+ def __init__(self, rows_source: Callable[[], list[dict]], ttl: float = 60):
122
+ self._src = rows_source
123
+ self.ttl = ttl
124
+ self._at = 0.0
125
+ self._stats: dict = {}
126
+ self._community: dict[str, CommunitySignal] = {}
127
+ self._lock = threading.Lock()
128
+
129
+ def _refresh(self):
130
+ with self._lock:
131
+ if time.time() - self._at < self.ttl and self._stats:
132
+ return
133
+ rows = self._src()
134
+ self._stats = compute(rows)
135
+ self._community = community_signals(rows)
136
+ self._at = time.time()
137
+
138
+ def invalidate(self):
139
+ self._at = 0.0
140
+
141
+ def stats(self) -> dict:
142
+ self._refresh()
143
+ return self._stats
144
+
145
+ def community(self) -> dict[str, CommunitySignal]:
146
+ self._refresh()
147
+ return self._community
bench/mlx_explorer_bench.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """MLX Model Explorer community benchmark.
3
+
4
+ Measures one MLX model on this Mac with mlx-lm and prints an anonymous JSON result:
5
+ prompt speed, generation speed, time to first token and peak memory. It uses the
6
+ same approach as `mlx_lm.benchmark` (random prompt tokens, EOS disabled, warm-up
7
+ run, several timed trials) and reports medians.
8
+
9
+ Nothing is sent anywhere unless you pass --submit URL, and even then the exact
10
+ payload is shown and you are asked to confirm. The payload holds no hostname,
11
+ username, paths or serial numbers: only the chip name (e.g. "Apple M3 Max"), the
12
+ RAM size class, OS major version and library versions.
13
+
14
+ pip install -U mlx-lm
15
+ python mlx_explorer_bench.py --model mlx-community/Qwen3-0.6B-4bit
16
+ python mlx_explorer_bench.py --model mlx-community/Qwen3-0.6B-4bit --submit https://<space-url>
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import platform
24
+ import statistics
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import urllib.error
29
+ import urllib.request
30
+
31
+ BENCHMARK_VERSION = "mlxbench-1"
32
+ RAM_CLASSES = [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192, 256, 512]
33
+ CONTEXTS = [4096, 8192, 16384, 32768, 65536, 131072, 262144]
34
+
35
+
36
+ def sysctl(name: str) -> str | None:
37
+ try:
38
+ return subprocess.run(["sysctl", "-n", name], capture_output=True, text=True, timeout=5).stdout.strip() or None
39
+ except (OSError, subprocess.SubprocessError):
40
+ return None
41
+
42
+
43
+ def chip_name() -> str:
44
+ brand = sysctl("machdep.cpu.brand_string") or ""
45
+ import re
46
+
47
+ m = re.match(r"^Apple (M[1-9])( (Pro|Max|Ultra))?", brand)
48
+ return f"Apple {m.group(1)}{m.group(2) or ''}" if m else "other"
49
+
50
+
51
+ def ram_class() -> int | None:
52
+ raw = sysctl("hw.memsize")
53
+ if not raw or not raw.isdigit():
54
+ return None
55
+ gb = int(raw) / 1024**3
56
+ return min(RAM_CLASSES, key=lambda c: abs(c - gb))
57
+
58
+
59
+ def context_bucket(tokens: int) -> int:
60
+ return next((c for c in CONTEXTS if c >= tokens), CONTEXTS[-1])
61
+
62
+
63
+ def run(args) -> dict:
64
+ import mlx.core as mx
65
+ import mlx_lm
66
+ from mlx_lm import load, stream_generate
67
+
68
+ mx.random.seed(0)
69
+ print(f"Loading {args.model} …", file=sys.stderr)
70
+ model, tokenizer, config = load(args.model, return_config=True)
71
+ tokenizer._eos_token_ids = {} # never stop early, like mlx_lm.benchmark
72
+ vocab = config.get("vocab_size") or (config.get("text_config") or {}).get("vocab_size")
73
+ prompt = mx.random.randint(0, vocab, (args.prompt_tokens,)).tolist()
74
+
75
+ def once():
76
+ start = time.perf_counter()
77
+ first = None
78
+ last = None
79
+ for resp in stream_generate(model, tokenizer, prompt, max_tokens=args.generation_tokens):
80
+ if first is None:
81
+ first = time.perf_counter() - start
82
+ last = resp
83
+ return last, first
84
+
85
+ print("Warm-up run …", file=sys.stderr)
86
+ once()
87
+ mx.reset_peak_memory()
88
+ trials = []
89
+ for i in range(args.trials):
90
+ resp, ttft = once()
91
+ trials.append((resp, ttft))
92
+ print(f"trial {i + 1}: prompt {resp.prompt_tps:.1f} tok/s, generation {resp.generation_tps:.1f} tok/s, "
93
+ f"TTFT {ttft * 1000:.0f} ms, peak {resp.peak_memory:.2f} GB", file=sys.stderr)
94
+
95
+ med = lambda xs: statistics.median(xs)
96
+ mac = platform.mac_ver()[0]
97
+ return {
98
+ "event_type": "mlx_benchmark_submission",
99
+ "benchmark_type": "mlx_lm",
100
+ "benchmark_version": BENCHMARK_VERSION,
101
+ "selected_model": args.model,
102
+ "prompt_tokens": args.prompt_tokens,
103
+ "generation_tokens": args.generation_tokens,
104
+ "prompt_tps": round(med([r.prompt_tps for r, _ in trials]), 2),
105
+ "generation_tps": round(med([r.generation_tps for r, _ in trials]), 2),
106
+ "ttft_ms": round(med([t for _, t in trials]) * 1000, 1),
107
+ "peak_memory_gb": round(max(r.peak_memory for r, _ in trials), 3),
108
+ "target_context": context_bucket(args.prompt_tokens + args.generation_tokens),
109
+ "chip": chip_name(),
110
+ "reported_ram_gb": ram_class(),
111
+ "macos_major": int(mac.split(".")[0]) if mac and mac.split(".")[0].isdigit() else None,
112
+ "mlx_version": getattr(mx, "__version__", None),
113
+ "mlx_lm_version": getattr(mlx_lm, "__version__", None),
114
+ }
115
+
116
+
117
+ def submit(result: dict, url: str, token: str | None, assume_yes: bool) -> int:
118
+ payload = {"events": [{k: v for k, v in result.items() if v is not None}]}
119
+ body = json.dumps(payload).encode()
120
+ print("\nThis exact payload will be sent to", url.rstrip("/") + "/api/events", file=sys.stderr)
121
+ print(json.dumps(payload, indent=2), file=sys.stderr)
122
+ if not assume_yes:
123
+ if input("Send it? [y/N] ").strip().lower() not in ("y", "yes"):
124
+ print("Not sent.", file=sys.stderr)
125
+ return 0
126
+ headers = {"content-type": "application/json", "user-agent": f"mlx-explorer-bench/{BENCHMARK_VERSION}"}
127
+ if token:
128
+ headers["authorization"] = f"Bearer {token}"
129
+ req = urllib.request.Request(url.rstrip("/") + "/api/events", data=body, headers=headers, method="POST")
130
+ try:
131
+ with urllib.request.urlopen(req, timeout=30) as r:
132
+ reply = json.loads(r.read() or b"{}")
133
+ flags = (reply.get("flags") or [[]])[0]
134
+ print("Submitted." + (f" Flagged for review: {', '.join(flags)}" if flags else " Thank you!"), file=sys.stderr)
135
+ return 0
136
+ except urllib.error.HTTPError as e:
137
+ print(f"Not accepted: HTTP {e.code} {e.read()[:500].decode(errors='replace')}", file=sys.stderr)
138
+ return 1
139
+ except urllib.error.URLError as e:
140
+ print(f"Couldn't reach {url}: {e.reason}", file=sys.stderr)
141
+ return 1
142
+
143
+
144
+ def main(argv=None) -> int:
145
+ p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
146
+ p.add_argument("--model", required=True, help="Hugging Face repo id, e.g. mlx-community/Qwen3-0.6B-4bit")
147
+ p.add_argument("--prompt-tokens", "-p", type=int, default=512)
148
+ p.add_argument("--generation-tokens", "-g", type=int, default=256)
149
+ p.add_argument("--trials", "-n", type=int, default=3)
150
+ p.add_argument("--submit", metavar="URL", help="MLX Model Explorer URL to submit the result to")
151
+ p.add_argument("--yes", action="store_true", help="submit without asking for confirmation")
152
+ p.add_argument("--hf-token", default=None,
153
+ help="only needed while the Space is private: a Hugging Face token sent as a bearer header")
154
+ args = p.parse_args(argv)
155
+ if not (1 <= args.prompt_tokens <= 1_048_576 and 1 <= args.generation_tokens <= 65_536 and 1 <= args.trials <= 20):
156
+ p.error("prompt/generation tokens or trials out of range")
157
+ if platform.system() != "Darwin" or platform.machine() != "arm64":
158
+ print("MLX runs on Apple Silicon Macs; this machine can't run the benchmark.", file=sys.stderr)
159
+ return 2
160
+ result = run(args)
161
+ print(json.dumps(result, indent=2))
162
+ if args.submit:
163
+ return submit(result, args.submit, args.hf_token, args.yes)
164
+ print("\nTo contribute this result, paste the JSON into MLX Model Explorer or rerun with --submit URL.", file=sys.stderr)
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ sys.exit(main())
data/catalogue_snapshot.json.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9d2dbfdeddb91d46cb16141299100e673e448e7e96fe7ca06f975cfaeb2539c3
3
+ size 357655
pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ addopts = -q
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.119.1
2
+ uvicorn[standard]==0.52.4
3
+ huggingface_hub==1.31.0
4
+ pyarrow==25.0.1
5
+ pydantic==2.13.5
6
+ httpx==0.28.1
static/app.js ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
+ (function () {
4
+ const $ = (s, r = document) => r.querySelector(s);
5
+ const $$ = (s, r = document) => [...r.querySelectorAll(s)];
6
+
7
+ // Build DOM safely: all text goes through text nodes, never innerHTML.
8
+ function el(tag, attrs, ...kids) {
9
+ const n = document.createElement(tag);
10
+ for (const [k, v] of Object.entries(attrs || {})) {
11
+ if (v == null || v === false) continue;
12
+ if (k === "class") n.className = v;
13
+ else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
14
+ else n.setAttribute(k, v === true ? "" : v);
15
+ }
16
+ for (const kid of kids.flat()) {
17
+ if (kid == null || kid === false) continue;
18
+ n.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
19
+ }
20
+ return n;
21
+ }
22
+
23
+ const TOP_FAMILIES = 9;
24
+ const RAM_CHIPS = [8, 16, 18, 24, 32, 36, 48, 64, 96, 128, 192];
25
+ const SIZE_LABEL = { "<3B": "Under 3B", "3-8B": "3–8B", "8-15B": "8–15B", "15-35B": "15–35B", "35-70B": "35–70B", "70B+": "70B+", MoE: "MoE" };
26
+ const PRIORITY_LABEL = { balanced: "Balanced", quality: "Quality", speed: "Speed", memory: "Least memory", long_context: "Long context" };
27
+ const CHIPS = [];
28
+ for (const g of [1, 2, 3, 4, 5]) for (const t of ["", " Pro", " Max", " Ultra"]) CHIPS.push(`Apple M${g}${t}`);
29
+
30
+ const state = {
31
+ meta: null,
32
+ sort: "recommended",
33
+ offset: 0,
34
+ results: [],
35
+ total: 0,
36
+ lastQuery: null,
37
+ hw: null,
38
+ ramSource: null, // "confirmed" | "estimated" | null
39
+ compare: new Map(),
40
+ pinned: null,
41
+ requestId: 0,
42
+ };
43
+
44
+ // ------------------------------------------------------------------ formatting
45
+ const fmtCtx = (n) => (n >= 262144 ? "256k" : n >= 1024 ? `${Math.round(n / 1024)}k` : String(n));
46
+ const fmtNum = (n) => (n == null ? "unknown" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
47
+ const fmtParams = (p) => (p == null ? "size unknown" : p >= 1e9 ? `${(p / 1e9).toFixed(p >= 1e10 ? 0 : 1)}B` : `${Math.round(p / 1e6)}M`);
48
+ const fmtGB = (g) => (g == null ? "unknown" : `${g < 10 ? g.toFixed(1) : Math.round(g)}\u00a0GB`);
49
+ function fmtAgo(iso) {
50
+ if (!iso) return "unknown";
51
+ const days = (Date.now() - Date.parse(iso)) / 864e5;
52
+ if (!isFinite(days)) return "unknown";
53
+ if (days < 1) return "today";
54
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"} ago`;
55
+ if (days < 31) return plural(Math.round(days), "day");
56
+ if (days < 365) return plural(Math.max(1, Math.round(days / 30.4)), "month");
57
+ const y = days / 365;
58
+ return `${y < 1.95 ? y.toFixed(1) : Math.round(y)} years ago`;
59
+ }
60
+ const fitText = (fit) => ({ Comfortable: "comfortable fit", Likely: "likely fits", Borderline: "borderline", Unlikely: "unlikely to fit" }[fit] || "");
61
+
62
+ // ------------------------------------------------------------------ telemetry
63
+ const Telemetry = (() => {
64
+ // A browser privacy signal (GPC / Do Not Track) means "off" unless the user explicitly opts in here.
65
+ const gpc = navigator.globalPrivacyControl === true || navigator.doNotTrack === "1";
66
+ let optedOut = gpc;
67
+ try {
68
+ if (localStorage.getItem("mme-optout") === "1") optedOut = true;
69
+ else if (gpc && localStorage.getItem("mme-optin") === "1") optedOut = false;
70
+ } catch (e) {}
71
+ let sid = null;
72
+ try { sid = sessionStorage.getItem("mme-sid"); } catch (e) {}
73
+ if (!sid || !/^[a-f0-9]{16}$/.test(sid)) {
74
+ const b = new Uint8Array(8);
75
+ crypto.getRandomValues(b);
76
+ sid = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
77
+ try { sessionStorage.setItem("mme-sid", sid); } catch (e) {}
78
+ }
79
+ let queue = [];
80
+ let enabled = true;
81
+
82
+ function send(beacon) {
83
+ if (!queue.length || optedOut || !enabled) { queue = []; return; }
84
+ const batch = queue.splice(0, 50);
85
+ const body = JSON.stringify({ events: batch });
86
+ if (beacon && navigator.sendBeacon) {
87
+ navigator.sendBeacon("/api/events", new Blob([body], { type: "application/json" }));
88
+ } else {
89
+ fetch("/api/events", { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});
90
+ }
91
+ if (queue.length) send(beacon);
92
+ }
93
+ setInterval(() => send(false), 10000);
94
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") send(true); });
95
+ window.addEventListener("pagehide", () => send(true));
96
+
97
+ return {
98
+ gpc,
99
+ get optedOut() { return optedOut; },
100
+ setOptOut(v) {
101
+ optedOut = v;
102
+ try {
103
+ if (v) { localStorage.setItem("mme-optout", "1"); localStorage.removeItem("mme-optin"); }
104
+ else { localStorage.removeItem("mme-optout"); if (gpc) localStorage.setItem("mme-optin", "1"); }
105
+ } catch (e) {}
106
+ if (optedOut) queue = [];
107
+ },
108
+ setEnabled(v) { enabled = v; },
109
+ track(type, fields) {
110
+ if (optedOut || !enabled) return;
111
+ const ev = { event_type: type, session_id: sid };
112
+ for (const [k, v] of Object.entries(fields || {})) if (v !== undefined && v !== null && v !== "") ev[k] = v;
113
+ queue.push(ev);
114
+ if (queue.length >= 20) send(false);
115
+ },
116
+ // Submissions the user explicitly asked to send go out immediately, with a result.
117
+ async submit(type, fields) {
118
+ if (optedOut) return { ok: false, message: "Sending is turned off in Privacy settings on this page." };
119
+ const ev = { event_type: type, session_id: sid, ...fields };
120
+ Object.keys(ev).forEach((k) => (ev[k] === undefined || ev[k] === null || ev[k] === "") && delete ev[k]);
121
+ try {
122
+ const r = await fetch("/api/events", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ events: [ev] }) });
123
+ const j = await r.json().catch(() => ({}));
124
+ if (r.status === 202) return { ok: true, flags: (j.flags || [])[0] || [] };
125
+ if (r.status === 429) return { ok: false, message: "Too many submissions from this network. Try again in a minute." };
126
+ const d = (j.details || []).map((x) => `${(x.loc || []).slice(-1)[0]}: ${x.msg}`).join("; ");
127
+ return { ok: false, message: `Not accepted${d ? ` (${d})` : ""}.` };
128
+ } catch (e) {
129
+ return { ok: false, message: "Couldn't reach the server. Check your connection and try again." };
130
+ }
131
+ },
132
+ };
133
+ })();
134
+
135
+ // ------------------------------------------------------------------ controls
136
+ function chip(name, value, label, small, checked) {
137
+ const input = el("input", { type: "radio", name, value });
138
+ input.checked = !!checked;
139
+ return el("label", { class: "chip" }, input, el("span", {}, label, small ? el("small", {}, small) : null));
140
+ }
141
+
142
+ function radioValue(name) {
143
+ const r = $(`#query input[name="${name}"]:checked`);
144
+ return r ? r.value : "";
145
+ }
146
+
147
+ function setRadio(name, value) {
148
+ const r = $$(`#query input[name="${name}"]`).find((x) => x.value === String(value));
149
+ if (r) { r.checked = true; return true; }
150
+ return false;
151
+ }
152
+
153
+ function uncheck(name) {
154
+ $$(`#query input[name="${name}"]`).forEach((x) => (x.checked = false));
155
+ }
156
+
157
+ function currentRam() {
158
+ const v = parseInt(radioValue("ram"), 10);
159
+ return Number.isFinite(v) ? v : null;
160
+ }
161
+
162
+ function currentFamily() {
163
+ return radioValue("family") || $("#family-more").value || "";
164
+ }
165
+
166
+ function buildControls(m) {
167
+ const ramOpts = RAM_CHIPS.filter((g) => m.ram_classes.includes(g));
168
+ $("#ram-chips").replaceChildren(chip("ram", "", "Not sure", null, true), ...ramOpts.map((g) => chip("ram", g, `${g} GB`)));
169
+ $("#context-chips").replaceChildren(...m.contexts.map((c) => chip("context", c, fmtCtx(c), null, c === 8192)));
170
+
171
+ const fams = m.families.filter((f) => f.family !== "Other");
172
+ $("#family-chips").replaceChildren(chip("family", "", "Any", null, true),
173
+ ...fams.slice(0, TOP_FAMILIES).map((f) => chip("family", f.family, f.family, String(f.count))));
174
+ const more = $("#family-more");
175
+ more.length = 1;
176
+ fams.slice(TOP_FAMILIES).forEach((f) => more.append(el("option", { value: f.family }, `${f.family} (${f.count})`)));
177
+ const other = m.families.find((f) => f.family === "Other");
178
+ if (other) more.append(el("option", { value: "Other" }, `Other (${other.count})`));
179
+
180
+ $("#size-chips").replaceChildren(chip("size", "", "Any", null, true), ...m.sizes.map((s) => chip("size", s, SIZE_LABEL[s] || s)));
181
+ const quants = m.quantizations.filter((q) => q !== "unknown");
182
+ $("#quant-chips").replaceChildren(chip("quant", "", "Any", null, true),
183
+ ...quants.map((q) => chip("quant", q, q.replace("-bit", " bit"))), chip("quant", "unknown", "Unknown"));
184
+ $("#priority-chips").replaceChildren(...m.priorities.map((p) => chip("priority", p, PRIORITY_LABEL[p] || p, null, p === "balanced")));
185
+ }
186
+
187
+ function hwFields() {
188
+ const f = {};
189
+ const ram = currentRam();
190
+ if (ram) f.hardware_memory_class = ram;
191
+ f.hardware_source = state.ramSource === "confirmed" ? "confirmed" : state.hw ? "detected" : "none";
192
+ if (state.hw) {
193
+ f.webgpu_available = state.hw.webgpu_available;
194
+ f.gpu_capability_class = state.hw.capability;
195
+ f.gpu_vendor = state.hw.gpu_vendor;
196
+ f.gpu_arch = state.hw.gpu_arch;
197
+ f.cpu_cores = state.hw.cpu_cores;
198
+ f.browser_family = state.hw.browser_family;
199
+ f.os_family = state.hw.os_family;
200
+ }
201
+ return f;
202
+ }
203
+
204
+ async function loadMeta() {
205
+ const r = await fetch("/api/meta");
206
+ if (!r.ok) throw new Error("meta");
207
+ const m = await r.json();
208
+ state.meta = m;
209
+ buildControls(m);
210
+ $("#app-version").textContent = m.version;
211
+ $("#engine-version").textContent = m.engine;
212
+ const c = m.catalogue;
213
+ $("#cat-status").textContent = `${c.models.toLocaleString()} models indexed${c.degraded ? " from a cached copy" : ""}`;
214
+ if (!m.collection_enabled) Telemetry.setEnabled(false);
215
+ restoreFromUrl();
216
+ }
217
+
218
+ function restoreFromUrl() {
219
+ const p = new URLSearchParams(location.search);
220
+ const fam = p.get("family");
221
+ if (fam && !setRadio("family", fam) && [...$("#family-more").options].some((o) => o.value === fam)) {
222
+ $("#family-more").value = fam;
223
+ uncheck("family");
224
+ }
225
+ ["size", "quant", "context", "priority"].forEach((k) => p.get(k) && setRadio(k, p.get(k)));
226
+ if (p.get("ram") && setRadio("ram", p.get("ram"))) state.ramSource = "confirmed";
227
+ updateRamHint();
228
+ }
229
+
230
+ function readQuery() {
231
+ const ram = currentRam();
232
+ return {
233
+ family: currentFamily() || null,
234
+ size: radioValue("size") || null,
235
+ quant: radioValue("quant") || null,
236
+ context: parseInt(radioValue("context"), 10) || 8192,
237
+ ram_gb: ram,
238
+ ram_source: ram ? (state.ramSource === "confirmed" ? "confirmed" : "estimated") : null,
239
+ priority: radioValue("priority") || "balanced",
240
+ sort: state.sort,
241
+ llm_only: !$("#non-llm").checked,
242
+ search: $("#search").value.trim() || null,
243
+ };
244
+ }
245
+
246
+ function syncUrl(q) {
247
+ const p = new URLSearchParams();
248
+ if (q.family) p.set("family", q.family);
249
+ if (q.size) p.set("size", q.size);
250
+ if (q.quant) p.set("quant", q.quant);
251
+ if (q.context !== 8192) p.set("context", q.context);
252
+ if (q.ram_gb && state.ramSource === "confirmed") p.set("ram", q.ram_gb);
253
+ if (q.priority !== "balanced") p.set("priority", q.priority);
254
+ history.replaceState(null, "", p.toString() ? `?${p}` : location.pathname);
255
+ }
256
+
257
+ function updateRamHint() {
258
+ if (!currentRam()) $("#ram-hint").textContent = "Or pick it above.";
259
+ else if (state.ramSource === "confirmed") $("#ram-hint").textContent = "Set by you.";
260
+ }
261
+
262
+ // ------------------------------------------------------------------ memory budget
263
+ function usableGB(ram) {
264
+ return ram ? ram * (ram <= 36 ? 0.67 : 0.75) : null;
265
+ }
266
+
267
+ // One scale per set of bars so they compare honestly. Very large models clip at
268
+ // twice the usable limit instead of squashing everything else.
269
+ function scaleFor(ram, totals) {
270
+ const usable = usableGB(ram);
271
+ const biggest = Math.max(0, ...totals.filter((t) => t != null));
272
+ if (usable) return Math.max(usable, Math.min(biggest, usable * 2)) * 1.06;
273
+ return Math.max(biggest, 4) * 1.1;
274
+ }
275
+
276
+ function paintBar(track, mem, scale, ram) {
277
+ const segs = track.querySelectorAll(".seg");
278
+ const limit = track.querySelector(".limit");
279
+ const w = mem && mem.weights_gb != null ? mem.weights_gb : 0;
280
+ const kv = mem && mem.kv_gb != null ? mem.kv_gb : 0;
281
+ const oh = mem && mem.overhead_gb != null ? mem.overhead_gb : 0;
282
+ const pct = (x) => `${Math.max(0, Math.min(100, (x / scale) * 100))}%`;
283
+ segs[0].style.width = pct(w);
284
+ segs[1].style.width = pct(Math.min(kv, Math.max(0, scale - w)));
285
+ segs[2].style.width = pct(Math.min(oh, Math.max(0, scale - w - kv)));
286
+ const usable = usableGB(ram);
287
+ limit.hidden = !usable;
288
+ if (usable) limit.style.left = pct(usable);
289
+ track.classList.toggle("over", !!(mem && usable && mem.total_gb > usable));
290
+ }
291
+
292
+ function miniBar(mem, scale, ram) {
293
+ const track = el("div", { class: "mini", "aria-hidden": "true" },
294
+ el("span", { class: "seg seg-w" }), el("span", { class: "seg seg-kv" }), el("span", { class: "seg seg-oh" }), el("span", { class: "limit" }));
295
+ paintBar(track, mem, scale, ram);
296
+ return track;
297
+ }
298
+
299
+ function renderBudget(r) {
300
+ const q = state.lastQuery || readQuery();
301
+ const ram = q.ram_gb;
302
+ const usable = usableGB(ram);
303
+ $("#budget-mac").textContent = ram ? `${ram} GB Mac${state.ramSource === "confirmed" ? "" : " (estimated)"}` : "Pick your Mac's memory to see what fits";
304
+ $("#budget-usable").textContent = usable ? `About ${Math.round(usable)} GB usable by the GPU` : "";
305
+ const mem = r && r.memory;
306
+ paintBar($("#budget-track"), mem, scaleFor(ram, [mem && mem.total_gb]), ram);
307
+ const line = $("#budget-model");
308
+ if (!r) {
309
+ line.textContent = state.lastQuery && state.total === 0 ? "No models match these filters." : "Loading models";
310
+ return;
311
+ }
312
+ const name = el("strong", {}, r.model.name);
313
+ if (!mem || mem.total_gb == null) {
314
+ line.replaceChildren(name, " has no size information, so its memory can't be estimated.");
315
+ return;
316
+ }
317
+ const parts = [name, ` at ${fmtCtx(q.context)} context needs about ${fmtGB(mem.total_gb)}`];
318
+ if (mem.fit) parts.push(": ", el("span", { class: `fit-${mem.fit}` }, fitText(mem.fit)), ".");
319
+ else parts.push(". Pick your Mac's memory to check the fit.");
320
+ parts.push(` ${fmtGB(mem.weights_gb)} of weights and ${fmtGB(mem.kv_gb)} of KV cache${mem.kv_rough ? " (rough)" : ""}.`);
321
+ line.replaceChildren(...parts);
322
+ $("#budget-track").setAttribute("aria-label",
323
+ `${r.model.name}: weights ${fmtGB(mem.weights_gb)}, KV cache ${fmtGB(mem.kv_gb)}, overhead ${fmtGB(mem.overhead_gb)}${usable ? `, GPU limit about ${Math.round(usable)} GB` : ""}`);
324
+ }
325
+
326
+ // ------------------------------------------------------------------ results
327
+ let debounce = null;
328
+ function scheduleExplore(reason) {
329
+ clearTimeout(debounce);
330
+ debounce = setTimeout(() => explore({ reason }), 220);
331
+ }
332
+
333
+ async function explore({ append = false, reason = "search" } = {}) {
334
+ const q = readQuery();
335
+ if (!append) state.offset = 0;
336
+ state.lastQuery = q;
337
+ const id = ++state.requestId;
338
+ $("#results").setAttribute("aria-busy", "true");
339
+ if (!append) $("#summary").textContent = "Finding models";
340
+ try {
341
+ const r = await fetch("/api/recommend", {
342
+ method: "POST",
343
+ headers: { "content-type": "application/json" },
344
+ body: JSON.stringify({ ...q, offset: state.offset, limit: 24 }),
345
+ });
346
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
347
+ const data = await r.json();
348
+ if (id !== state.requestId) return; // a newer query superseded this one
349
+ state.total = data.total;
350
+ state.results = append ? state.results.concat(data.results) : data.results;
351
+ renderResults(data, append);
352
+ syncUrl(q);
353
+ if (!append) {
354
+ Telemetry.track(reason, {
355
+ model_family: q.family, parameter_bucket: q.size, quantization: q.quant, target_context: q.context,
356
+ priority: q.priority, sort: q.sort, result_count: data.total, ...hwFields(),
357
+ });
358
+ }
359
+ } catch (e) {
360
+ if (id !== state.requestId) return;
361
+ $("#summary").textContent = "";
362
+ $("#notices").replaceChildren(el("p", { class: "note" }, "Models couldn't be loaded. Check your connection, then change a filter to retry."));
363
+ } finally {
364
+ if (id === state.requestId) $("#results").removeAttribute("aria-busy");
365
+ }
366
+ }
367
+
368
+ function describeQuery(q) {
369
+ return [q.family || "all families", q.size ? SIZE_LABEL[q.size] || q.size : null, q.quant, `${fmtCtx(q.context)} context`]
370
+ .filter(Boolean).join(", ");
371
+ }
372
+
373
+ function renderResults(data, append) {
374
+ const q = state.lastQuery;
375
+ $("#summary").textContent = `${data.total.toLocaleString()} ${data.total === 1 ? "model" : "models"} for ${describeQuery(q)}`;
376
+ const notices = data.notices.map((n) => el("p", { class: "note" }, n));
377
+ if (!data.total) notices.push(el("p", { class: "note" }, "Nothing matches. Set Size or Quantization to Any, or clear the name filter."));
378
+ $("#notices").replaceChildren(...notices);
379
+ const list = $("#list");
380
+ if (!append) list.replaceChildren();
381
+ const scale = scaleFor(q.ram_gb, state.results.map((r) => r.memory.total_gb));
382
+ if (append) $$(".row .mini", list).forEach((bar, i) => state.results[i] && paintBar(bar, state.results[i].memory, scale, q.ram_gb));
383
+ data.results.forEach((r, i) => list.append(resultRow(r, data.offset + i, scale)));
384
+ state.offset = data.offset + data.results.length;
385
+ $("#more").hidden = state.offset >= data.total;
386
+ if (!append) {
387
+ state.pinned = state.results[0] || null;
388
+ renderBudget(state.pinned);
389
+ }
390
+ }
391
+
392
+ function resultRow(r, rank, scale) {
393
+ const m = r.model;
394
+ const mem = r.memory;
395
+ const q = state.lastQuery;
396
+ const quant = r.quant || m.quant;
397
+ const cmp = el("input", { type: "checkbox" });
398
+ cmp.checked = state.compare.has(m.id);
399
+ cmp.addEventListener("change", () => toggleCompare(r, cmp, rank));
400
+ const fitLabel = mem.fit
401
+ ? el("span", { class: `fit-label fit-${mem.fit}` }, mem.fit)
402
+ : el("span", { class: "fit-label fit-none" }, mem.total_gb == null ? "size unknown" : q.ram_gb ? "unknown" : "set memory");
403
+ const moe = m.moe ? (m.active_params ? `, MoE with ${fmtParams(m.active_params)} active` : ", MoE") : "";
404
+ const row = el("li", { class: "row" },
405
+ el("div", { class: "row-main" },
406
+ el("div", { class: "row-title" },
407
+ el("button", { class: "row-name", type: "button", onclick: () => openDetail(m.id, rank, r) }, m.name)),
408
+ el("div", { class: "facts" },
409
+ el("span", { class: "tag" }, quant.label === "unknown" ? "quantization unknown" : quant.label),
410
+ el("span", {}, fmtParams(m.params) + moe),
411
+ m.pipeline === "image-text-to-text" ? el("span", {}, "vision") : null,
412
+ m.partial ? el("span", { class: "warn", title: "The repo holds far fewer weights than its name suggests" }, "partial or add-on") : null,
413
+ el("span", {}, `${fmtNum(m.downloads)} downloads`),
414
+ m.likes ? el("span", {}, `${fmtNum(m.likes)} likes`) : null,
415
+ el("span", {}, `updated ${fmtAgo(m.modified)}`),
416
+ r.community.benchmark_count ? el("span", {}, `${r.community.benchmark_count} MLX benchmark${r.community.benchmark_count > 1 ? "s" : ""}`) : null),
417
+ el("p", { class: "why" }, rowReason(r)),
418
+ el("div", { class: "row-actions" },
419
+ el("button", { type: "button", class: "linkish", onclick: () => openDetail(m.id, rank, r) }, "Details"),
420
+ el("label", { class: "check" }, cmp, "Compare"),
421
+ el("a", { href: m.url, target: "_blank", rel: "noopener", onclick: () => trackClick(m.id, rank, r) }, "View on Hugging Face"))),
422
+ el("div", { class: "row-fit" },
423
+ el("div", { class: "fit-line" }, el("span", { class: "fit-gb" }, mem.total_gb == null ? "?" : fmtGB(mem.total_gb)), fitLabel),
424
+ miniBar(mem, scale, q.ram_gb),
425
+ state.sort === "recommended" ? el("span", { class: "score", title: "Best-fit score from 0 to 100, explained under How it works" }, `Score ${Math.round(r.score)}`) : null));
426
+ const show = () => {
427
+ $$(".row.active").forEach((x) => x.classList.remove("active"));
428
+ row.classList.add("active");
429
+ renderBudget(r);
430
+ };
431
+ row.addEventListener("mouseenter", show);
432
+ row.addEventListener("focusin", show);
433
+ return row;
434
+ }
435
+
436
+ // The bar already shows fit and size, so lead with the reason that adds something.
437
+ function rowReason(r) {
438
+ const skip = /fit:|^Needs roughly|downloads\.$|^KV cache at/;
439
+ return r.reasons.find((t) => !skip.test(t)) || r.reasons[0] || "";
440
+ }
441
+
442
+ function shown(r) {
443
+ return r ? { recommendation_score: Math.round(r.score * 10) / 10, fit_class: r.memory && r.memory.fit } : {};
444
+ }
445
+
446
+ function trackClick(id, rank, r) {
447
+ const q = state.lastQuery || readQuery();
448
+ Telemetry.track("model_click", { selected_model: id, selected_model_rank: rank, target_context: q.context, priority: q.priority, ...shown(r), ...hwFields() });
449
+ }
450
+
451
+ // ------------------------------------------------------------------ compare
452
+ function toggleCompare(r, box, rank) {
453
+ if (box.checked) {
454
+ if (state.compare.size >= 4) {
455
+ box.checked = false;
456
+ flashTray("Compare up to 4 models at a time.");
457
+ return;
458
+ }
459
+ state.compare.set(r.model.id, r);
460
+ const q = state.lastQuery || readQuery();
461
+ Telemetry.track("model_select", { selected_model: r.model.id, selected_model_rank: rank, target_context: q.context, priority: q.priority, ...shown(r), ...hwFields() });
462
+ } else {
463
+ state.compare.delete(r.model.id);
464
+ }
465
+ renderTray();
466
+ }
467
+
468
+ function flashTray(msg) {
469
+ renderTray();
470
+ $("#compare-tray").hidden = false;
471
+ $("#compare-count").textContent = msg;
472
+ setTimeout(renderTray, 2200);
473
+ }
474
+
475
+ function renderTray() {
476
+ const n = state.compare.size;
477
+ $("#compare-tray").hidden = n === 0;
478
+ $("#compare-count").textContent = n === 1 ? "1 selected, pick another to compare" : `${n} models selected`;
479
+ $("#compare-open").disabled = n < 2;
480
+ }
481
+
482
+ function relQuality(bits) {
483
+ if (bits == null) return "unknown";
484
+ if (bits >= 16) return "reference (unquantized)";
485
+ if (bits >= 8) return "near reference";
486
+ if (bits >= 5) return "high";
487
+ if (bits >= 4) return "good";
488
+ if (bits >= 3) return "lower";
489
+ return "lowest";
490
+ }
491
+
492
+ function openCompare() {
493
+ const items = [...state.compare.values()];
494
+ const q = state.lastQuery || readQuery();
495
+ Telemetry.track("compare", { compare_models: items.map((x) => x.model.id), target_context: q.context, priority: q.priority, ...hwFields() });
496
+ const scale = scaleFor(q.ram_gb, items.map((r) => r.memory.total_gb));
497
+ const qt = (r) => r.quant || r.model.quant;
498
+ const rows = [
499
+ ["Quantization", (r) => qt(r).label],
500
+ ["Parameters", (r) => fmtParams(r.model.params) + (r.model.moe ? ", MoE" : "")],
501
+ [`Memory at ${fmtCtx(q.context)}`, (r) => fmtGB(r.memory.total_gb)],
502
+ ["Memory budget", (r) => miniBar(r.memory, scale, q.ram_gb), "compare-bar"],
503
+ ["Weights", (r) => fmtGB(r.memory.weights_gb)],
504
+ ["KV cache", (r) => (r.memory.kv_gb == null ? "unknown" : fmtGB(r.memory.kv_gb) + (r.memory.kv_rough ? " (rough)" : ""))],
505
+ ["Fit", (r) => r.memory.fit || (q.ram_gb ? "unknown" : "set your memory")],
506
+ ["Relative quality", (r) => relQuality(qt(r).bits)],
507
+ ["Speed", (r) => (r.community.median_generation_tps ? `${r.community.median_generation_tps} tok/s median from community benchmarks` : "unknown, no community benchmarks yet")],
508
+ ["Downloads", (r) => fmtNum(r.model.downloads)],
509
+ ["Likes", (r) => fmtNum(r.model.likes)],
510
+ ["Updated", (r) => fmtAgo(r.model.modified)],
511
+ ["Community reports", (r) => (r.community.feedback_count ? `${r.community.feedback_count}${r.community.positive_share != null ? `, ${Math.round(r.community.positive_share * 100)}% positive` : ""}` : "none yet")],
512
+ ];
513
+ const table = el("table", {},
514
+ el("thead", {}, el("tr", {}, el("th", {}, ""), items.map((r) => el("th", {}, el("a", { href: r.model.url, target: "_blank", rel: "noopener" }, r.model.name))))),
515
+ el("tbody", {}, rows.map(([label, f, cls]) => el("tr", { class: cls || null }, el("th", {}, label), items.map((r) => el("td", {}, f(r)))))));
516
+ $("#c-body").replaceChildren(
517
+ el("div", { class: "table-scroll" }, table),
518
+ el("p", { class: "scale-note" }, "Quality is relative within the same base model: more bits usually keep more quality, by an amount that varies. Speed appears only once people have benchmarked a model."));
519
+ $("#compare").showModal();
520
+ }
521
+
522
+ // ------------------------------------------------------------------ detail sheet
523
+ async function openDetail(id, rank, r) {
524
+ const q = state.lastQuery || readQuery();
525
+ const dlg = $("#detail");
526
+ $("#d-title").textContent = id.split("/")[1];
527
+ $("#d-body").replaceChildren(el("p", { class: "fine" }, "Loading model details"));
528
+ if (!dlg.open) dlg.showModal();
529
+ Telemetry.track("model_view", { selected_model: id, selected_model_rank: rank ?? null, target_context: q.context, priority: q.priority, ...shown(r), ...hwFields() });
530
+ const params = new URLSearchParams({ context: q.context, priority: q.priority });
531
+ if (q.ram_gb) { params.set("ram_gb", q.ram_gb); if (q.ram_source) params.set("ram_source", q.ram_source); }
532
+ let d;
533
+ try {
534
+ const resp = await fetch(`/api/model/${id.split("/").map(encodeURIComponent).join("/")}?${params}`);
535
+ if (!resp.ok) throw new Error(String(resp.status));
536
+ d = await resp.json();
537
+ } catch (e) {
538
+ $("#d-body").replaceChildren(el("p", { class: "note" }, "Some model information isn't available right now. The Hugging Face page has the full details."),
539
+ el("p", {}, el("a", { href: `https://huggingface.co/${id}`, target: "_blank", rel: "noopener" }, "View on Hugging Face")));
540
+ return;
541
+ }
542
+ renderDetail(d, q, rank);
543
+ }
544
+
545
+ function renderDetail(d, q, rank) {
546
+ const m = d.model;
547
+ const quant = d.quant || m.quant;
548
+ const weights = d.files.filter((f) => /\.(safetensors|npz|gguf)$/.test(f.path));
549
+ const ctxRows = d.memory_by_context;
550
+ const scale = scaleFor(q.ram_gb, ctxRows.map((x) => x.total_gb));
551
+ const ctxBars = el("div", { class: "ctx-bars" }, ctxRows.flatMap((x) => [
552
+ el("span", { class: x.context === q.context ? "ctx cur" : "ctx" }, fmtCtx(x.context)),
553
+ miniBar(x, scale, q.ram_gb),
554
+ el("span", { class: "gb" }, fmtGB(x.total_gb)),
555
+ el("span", { class: `fitc fit-label ${x.exceeds_model_context || !x.fit ? "fit-none" : `fit-${x.fit}`}` },
556
+ x.exceeds_model_context ? "beyond max context" : x.fit || (q.ram_gb ? "unknown" : "set memory")),
557
+ ]));
558
+ const sibs = d.siblings.length ? [
559
+ el("h3", {}, "Other quantizations of this base model"),
560
+ el("div", { class: "table-scroll" }, el("table", {},
561
+ el("thead", {}, el("tr", {}, ["Model", "Quantization", `Memory at ${fmtCtx(q.context)}`, "Fit", "Downloads"].map((h, i) => el("th", { class: i === 4 ? "num" : null }, h)))),
562
+ el("tbody", {}, d.siblings.map((s) => el("tr", {},
563
+ el("td", {}, el("button", { type: "button", class: "linkish", onclick: () => openDetail(s.model.id, null, s) }, s.model.name)),
564
+ el("td", {}, (s.quant || s.model.quant).label),
565
+ el("td", {}, fmtGB(s.memory.total_gb)),
566
+ el("td", { class: s.memory.fit ? `fit-${s.memory.fit}` : null }, s.memory.fit || "unknown"),
567
+ el("td", { class: "num" }, fmtNum(s.model.downloads)))))))] : [];
568
+ const comm = d.community;
569
+ const moe = m.moe ? (m.active_params ? `, MoE with ${fmtParams(m.active_params)} active` : ", MoE") : "";
570
+ const body = [
571
+ el("p", { class: "sheet-lede" },
572
+ el("span", {}, quant.label + (quant.mode && !["affine", "float"].includes(quant.mode) ? ` (${quant.mode})` : "")),
573
+ el("span", {}, fmtParams(m.params) + moe),
574
+ d.memory.fit ? el("span", { class: `fit-label fit-${d.memory.fit}` }, `${d.memory.fit} at ${fmtCtx(q.context)}`) : null),
575
+ el("p", {}, el("a", { href: m.url, target: "_blank", rel: "noopener", onclick: () => trackClick(m.id, rank, null) }, `View ${m.id} on Hugging Face`)),
576
+ d.detail_error ? el("p", { class: "note" }, "Some model information isn't available: the config or file list couldn't be read, so estimates use parameter counts.") : null,
577
+ el("h3", {}, "Memory by context length"),
578
+ ctxBars,
579
+ el("p", { class: "scale-note" }, `Estimates, not measurements: weights + fp16 KV cache + about 1 GB overhead.${q.ram_gb ? ` The line marks about ${Math.round(usableGB(q.ram_gb))} GB usable by the GPU on a ${q.ram_gb} GB Mac.` : ""}`),
580
+ el("h3", {}, "Why it scores this way"),
581
+ el("ul", { class: "why-list" }, d.reasons.map((t) => el("li", {}, t))),
582
+ el("h3", {}, "About this repo"),
583
+ el("dl", { class: "kv" },
584
+ el("dt", {}, "Family"), el("dd", {}, m.family),
585
+ el("dt", {}, "Base model"), el("dd", {}, m.base_model || "unknown"),
586
+ el("dt", {}, "Pipeline"), el("dd", {}, m.pipeline || "unknown"),
587
+ el("dt", {}, "Downloads"), el("dd", {}, `${fmtNum(m.downloads)} in the last 30 days`),
588
+ el("dt", {}, "Likes"), el("dd", {}, fmtNum(m.likes)),
589
+ el("dt", {}, "Updated"), el("dd", {}, m.modified ? `${m.modified.slice(0, 10)}, ${fmtAgo(m.modified)}` : "unknown"),
590
+ el("dt", {}, "Files"), el("dd", {}, `${d.file_count} files, ${weights.length} weight ${weights.length === 1 ? "shard" : "shards"}`),
591
+ el("dt", {}, "Sources"), el("dd", {}, `quantization from ${quant.source}, size from ${m.params_source}, weights from ${d.memory.weights_source === "files" ? "exact file sizes" : "parameter count"}`)),
592
+ ...sibs,
593
+ el("h3", {}, "Community data"),
594
+ el("p", {}, comm.benchmark_count || comm.feedback_count
595
+ ? `${comm.benchmark_count} MLX benchmark${comm.benchmark_count === 1 ? "" : "s"}${comm.median_generation_tps ? `, median ${comm.median_generation_tps} tok/s generation` : ""}. ${comm.feedback_count} report${comm.feedback_count === 1 ? "" : "s"}${comm.positive_share != null ? `, ${Math.round(comm.positive_share * 100)}% positive` : ""}.`
596
+ : "No benchmarks or reports yet. Run this on your Mac to add the first:"),
597
+ el("pre", { class: "cmd" }, el("code", {}, benchCommand(m.id))),
598
+ feedbackForm(m, q),
599
+ ];
600
+ $("#d-body").replaceChildren(...body.filter(Boolean));
601
+ }
602
+
603
+ function feedbackForm(m, q) {
604
+ const f = $("#feedback-tpl").content.firstElementChild.cloneNode(true);
605
+ const ram = $("select[name=ram]", f);
606
+ (state.meta ? state.meta.ram_classes : []).forEach((g) => ram.append(el("option", { value: g }, `${g} GB`)));
607
+ if (currentRam() && state.ramSource === "confirmed") ram.value = String(currentRam());
608
+ const chipSel = $("select[name=chip]", f);
609
+ CHIPS.forEach((c) => chipSel.append(el("option", { value: c }, c.replace("Apple ", ""))));
610
+ chipSel.append(el("option", { value: "other" }, "Other"));
611
+ const ctx = $("select[name=ctx]", f);
612
+ (state.meta ? state.meta.contexts : []).forEach((c) => ctx.append(el("option", { value: c }, fmtCtx(c))));
613
+ $$("input[name=tried]", f).forEach((r) => r.addEventListener("change", () => { $(".fb-yes", f).hidden = !(r.checked && r.value === "yes"); }));
614
+ f.addEventListener("submit", async (e) => {
615
+ e.preventDefault();
616
+ const out = $(".fb-out", f);
617
+ const tried = ($("input[name=tried]:checked", f) || {}).value;
618
+ if (!tried) { out.textContent = "Choose Yes, No or Planning to."; return; }
619
+ const fields = { selected_model: m.id, tried, target_context: q.context, priority: q.priority, ...hwFields() };
620
+ if (tried === "yes") {
621
+ const oc = ($("input[name=outcome]:checked", f) || {}).value;
622
+ if (oc && oc.startsWith("q:")) fields.quality_rating = oc.slice(2);
623
+ if (oc && oc.startsWith("f:")) fields.failure_reason = oc.slice(2);
624
+ fields.reported_ram_gb = parseInt(ram.value, 10) || null;
625
+ fields.reported_mac_model = chipSel.value || null;
626
+ const tps = parseFloat($("input[name=tps]", f).value);
627
+ fields.reported_tokens_per_second = Number.isFinite(tps) && tps > 0 ? tps : null;
628
+ fields.reported_context = parseInt(ctx.value, 10) || null;
629
+ fields.notes = $("textarea[name=notes]", f).value.trim() || null;
630
+ }
631
+ const btn = $("button[type=submit]", f);
632
+ btn.disabled = true;
633
+ const res = await Telemetry.submit("feedback", fields);
634
+ btn.disabled = false;
635
+ out.textContent = res.ok ? "Report sent anonymously. Thank you." : res.message;
636
+ if (res.ok) $$("input, select, textarea, button", f).forEach((x) => (x.disabled = true));
637
+ });
638
+ return f;
639
+ }
640
+
641
+ // ------------------------------------------------------------------ hardware
642
+ async function detectHardware() {
643
+ const btn = $("#detect");
644
+ btn.disabled = true;
645
+ btn.textContent = "Detecting";
646
+ $("#hw-error").hidden = true;
647
+ let hw;
648
+ try {
649
+ hw = await window.HW.detect();
650
+ } catch (e) {
651
+ hw = { webgpu_available: false, error: "failed", capability: "unknown", memory_prior: null, browser_family: "other", os_family: "other" };
652
+ }
653
+ state.hw = hw;
654
+ btn.disabled = false;
655
+ btn.textContent = "Detect again";
656
+ const row = (k, v) => [el("dt", {}, k), el("dd", {}, v)];
657
+ $("#hw-facts").replaceChildren(
658
+ ...row("GPU", hw.gpu_vendor ? `${hw.gpu_vendor}${hw.gpu_arch ? `, ${hw.gpu_arch}` : ""}` : hw.webgpu_available ? "not exposed" : "WebGPU unavailable"),
659
+ ...row("Compute", hw.quick_score != null ? `${hw.quick_score.toLocaleString()}, ${hw.capability_label.toLowerCase()}` : "not measured"),
660
+ ...row("CPU threads", hw.cpu_cores || "unknown"));
661
+ $("#hw-out").hidden = false;
662
+ const msgs = [];
663
+ if (!hw.webgpu_available) msgs.push("WebGPU isn't available in this browser. You can enter your memory manually.");
664
+ if (hw.os_family && !["macos", "other"].includes(hw.os_family)) msgs.push("This doesn't look like a Mac. MLX runs on Apple Silicon, so pick the memory of the Mac you'll use.");
665
+ const prior = hw.memory_prior;
666
+ if (prior) {
667
+ $("#hw-estimate").textContent = `Estimated capability: ${prior.label}. Is that right? Pick your exact memory above.`;
668
+ if (!currentRam() || state.ramSource !== "confirmed") {
669
+ setRadio("ram", prior.ram);
670
+ state.ramSource = "estimated";
671
+ $("#ram-hint").textContent = `Estimated: ${prior.note}.`;
672
+ }
673
+ } else {
674
+ $("#hw-estimate").textContent = "We couldn't reliably detect your memory configuration. Pick it above.";
675
+ }
676
+ if (msgs.length) { $("#hw-error").textContent = msgs.join(" "); $("#hw-error").hidden = false; }
677
+ $("#full-bench").disabled = !hw.webgpu_available;
678
+ Telemetry.track("hardware_test", {
679
+ ...hwFields(), hardware_source: "detected", webgpu_score: hw.quick_score,
680
+ benchmark_type: hw.quick_score != null ? "webgpu_quick" : null,
681
+ benchmark_version: hw.quick_score != null ? window.HW.BENCH_VERSION : null,
682
+ benchmark_duration_ms: hw.duration_ms,
683
+ });
684
+ explore({ reason: "filter" });
685
+ }
686
+
687
+ async function runFullBench() {
688
+ const btn = $("#full-bench");
689
+ const prog = $("#bench-progress");
690
+ btn.disabled = true;
691
+ prog.hidden = false;
692
+ prog.value = 0;
693
+ $("#bench-out").textContent = "Running. Keep this tab in front for 20 seconds.";
694
+ try {
695
+ const res = await window.HW.fullBenchmark((p) => { prog.value = p; });
696
+ $("#bench-out").textContent = `WebGPU compute score: ${res.score.toLocaleString()}. A relative browser GPU score for grouping hardware, not MLX speed or tokens per second.`;
697
+ Telemetry.track("browser_benchmark", {
698
+ ...hwFields(), webgpu_score: res.score, benchmark_type: "webgpu_full", benchmark_version: res.version,
699
+ benchmark_duration_ms: res.duration_ms, gpu_capability_class: window.HW.capabilityClass(state.hw && state.hw.quick_score),
700
+ });
701
+ } catch (e) {
702
+ $("#bench-out").textContent = "The GPU test couldn't run in this browser.";
703
+ } finally {
704
+ btn.disabled = false;
705
+ prog.hidden = true;
706
+ }
707
+ }
708
+
709
+ // ------------------------------------------------------------------ MLX benchmark section
710
+ function benchCommand(model) {
711
+ const origin = location.origin;
712
+ return [
713
+ "pip install -U mlx-lm",
714
+ `curl -fsSLO ${origin}/bench/mlx_explorer_bench.py`,
715
+ `python mlx_explorer_bench.py --model ${model}`,
716
+ `# to contribute the result, add: --submit ${origin}`,
717
+ ].join("\n");
718
+ }
719
+
720
+ function updateBenchCmd() {
721
+ const v = $("#bench-model").value.trim();
722
+ const model = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) ? v : "mlx-community/MODEL";
723
+ $("#bench-cmd").textContent = benchCommand(model);
724
+ }
725
+
726
+ const BENCH_KEYS = ["selected_model", "benchmark_type", "benchmark_version", "prompt_tokens", "generation_tokens", "prompt_tps",
727
+ "generation_tps", "ttft_ms", "peak_memory_gb", "chip", "reported_ram_gb", "mlx_version", "mlx_lm_version", "macos_major", "target_context"];
728
+
729
+ async function submitBenchJson() {
730
+ const out = $("#bench-submit-out");
731
+ let obj;
732
+ try {
733
+ obj = JSON.parse($("#bench-json").value);
734
+ } catch (e) {
735
+ out.textContent = "That isn't valid JSON. Paste the block the script printed.";
736
+ return;
737
+ }
738
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) { out.textContent = "Paste a single JSON object, the block the script printed."; return; }
739
+ const fields = {};
740
+ for (const k of BENCH_KEYS) if (k in obj) fields[k] = obj[k];
741
+ const res = await Telemetry.submit("mlx_benchmark_submission", fields);
742
+ out.textContent = res.ok
743
+ ? res.flags.length ? `Result recorded and flagged for review: ${res.flags.join(", ")}.` : "Result recorded anonymously. Thank you."
744
+ : res.message;
745
+ }
746
+
747
+ // ------------------------------------------------------------------ wiring
748
+ function init() {
749
+ const form = $("#query");
750
+ form.addEventListener("submit", (e) => e.preventDefault());
751
+ form.addEventListener("change", (e) => {
752
+ const t = e.target;
753
+ if (t.id === "search") return;
754
+ if (t.name === "ram") {
755
+ state.ramSource = t.value ? "confirmed" : null;
756
+ updateRamHint();
757
+ scheduleExplore("filter");
758
+ return;
759
+ }
760
+ if (t.name === "family") $("#family-more").value = "";
761
+ if (t.id === "family-more") { if (t.value) uncheck("family"); else setRadio("family", ""); }
762
+ scheduleExplore("search");
763
+ });
764
+ let typing = null;
765
+ $("#search").addEventListener("input", () => { clearTimeout(typing); typing = setTimeout(() => explore({ reason: "search" }), 350); });
766
+ $$("#sort button").forEach((b) => b.addEventListener("click", () => {
767
+ state.sort = b.dataset.sort;
768
+ $$("#sort button").forEach((x) => x.setAttribute("aria-selected", String(x === b)));
769
+ explore({ reason: "filter" });
770
+ }));
771
+ $("#list").addEventListener("mouseleave", () => {
772
+ $$(".row.active").forEach((x) => x.classList.remove("active"));
773
+ renderBudget(state.pinned);
774
+ });
775
+ $("#more").addEventListener("click", () => explore({ append: true }));
776
+ $("#detect").addEventListener("click", detectHardware);
777
+ $("#full-bench").addEventListener("click", runFullBench);
778
+ $("#compare-open").addEventListener("click", openCompare);
779
+ $("#compare-clear").addEventListener("click", () => {
780
+ state.compare.clear();
781
+ $$("#list .row-actions input[type=checkbox]").forEach((x) => (x.checked = false));
782
+ renderTray();
783
+ });
784
+ $$("dialog [data-close]").forEach((b) => b.addEventListener("click", () => b.closest("dialog").close()));
785
+ $$("dialog").forEach((d) => d.addEventListener("click", (e) => { if (e.target === d) d.close(); }));
786
+ $("#bench-model").addEventListener("input", updateBenchCmd);
787
+ $("#copy-cmd").addEventListener("click", async () => {
788
+ const b = $("#copy-cmd");
789
+ try { await navigator.clipboard.writeText($("#bench-cmd").textContent); b.textContent = "Copied"; }
790
+ catch (e) { b.textContent = "Select the text to copy"; }
791
+ setTimeout(() => (b.textContent = "Copy command"), 1600);
792
+ });
793
+ $("#bench-submit").addEventListener("click", submitBenchJson);
794
+ const opt = $("#optout");
795
+ opt.checked = Telemetry.optedOut;
796
+ if (Telemetry.gpc) $("#gpc-note").hidden = false;
797
+ opt.addEventListener("change", () => Telemetry.setOptOut(opt.checked));
798
+ // On phones the rail sits above the results: keep only memory and context open.
799
+ const hasFilters = /[?&](family|size|quant|priority)=/.test(location.search);
800
+ if (window.matchMedia("(max-width: 900px)").matches && !hasFilters) $("#more-filters").open = false;
801
+ updateBenchCmd();
802
+ loadMeta()
803
+ .then(() => explore())
804
+ .catch(() => {
805
+ $("#notices").replaceChildren(el("p", { class: "note" }, "The model catalogue couldn't be loaded. Refresh the page in a moment."));
806
+ $("#budget-model").textContent = "The model catalogue couldn't be loaded.";
807
+ });
808
+ }
809
+
810
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
811
+ else init();
812
+ })();
static/index.html ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>MLX Model Explorer</title>
7
+ <meta name="description" content="Find an MLX model that fits your Mac. Compare mlx-community models by memory, quantization, popularity and community experience.">
8
+ <meta name="color-scheme" content="light dark">
9
+ <link rel="stylesheet" href="/static/styles.css">
10
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317212B'/%3E%3Crect x='6' y='13' width='11' height='6' rx='1.5' fill='%234F86C6'/%3E%3Crect x='17' y='13' width='5' height='6' rx='1.5' fill='%239C7FD6'/%3E%3Crect x='24.5' y='9' width='1.5' height='14' rx='.75' fill='%23fff'/%3E%3C/svg%3E">
11
+ </head>
12
+ <body>
13
+ <a class="skip" href="#results">Skip to results</a>
14
+
15
+ <header class="top">
16
+ <div class="shell top-row">
17
+ <a class="brand" href="/" aria-label="MLX Model Explorer home">
18
+ <svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="12" width="14" height="8" rx="2" class="fill-w"/><rect x="17" y="12" width="6" height="8" rx="2" class="fill-kv"/><rect x="26" y="7" width="2" height="18" rx="1" class="fill-limit"/></svg>
19
+ <span>MLX Model Explorer</span>
20
+ </a>
21
+ <nav aria-label="Sections">
22
+ <a href="#benchmark">Benchmark</a>
23
+ <a href="#methodology">How it works</a>
24
+ <a href="#privacy">Privacy</a>
25
+ <a href="/stats">Community stats</a>
26
+ </nav>
27
+ </div>
28
+ </header>
29
+
30
+ <main>
31
+ <section class="hero shell" aria-labelledby="hero-title">
32
+ <div class="hero-copy">
33
+ <h1 id="hero-title">Find an MLX model that fits your Mac.</h1>
34
+ <p class="lede">Every model in <a href="https://huggingface.co/mlx-community" target="_blank" rel="noopener">mlx-community</a>, sized against the memory your GPU can actually use: weights, KV cache for your context, and runtime overhead.</p>
35
+ </div>
36
+
37
+ <figure class="budget" id="budget" aria-live="polite">
38
+ <figcaption class="budget-head">
39
+ <span class="budget-mac" id="budget-mac">Pick your Mac's memory to see what fits</span>
40
+ <span class="budget-usable" id="budget-usable"></span>
41
+ </figcaption>
42
+ <div class="budget-track" id="budget-track" role="img" aria-label="Memory budget">
43
+ <span class="seg seg-w" id="seg-w"></span><span class="seg seg-kv" id="seg-kv"></span><span class="seg seg-oh" id="seg-oh"></span>
44
+ <span class="limit" id="budget-limit" hidden></span>
45
+ </div>
46
+ <div class="budget-foot">
47
+ <p class="budget-model" id="budget-model">Loading models</p>
48
+ <ul class="legend" aria-label="Legend">
49
+ <li><i class="sw seg-w"></i>Weights</li>
50
+ <li><i class="sw seg-kv"></i>KV cache</li>
51
+ <li><i class="sw seg-oh"></i>Overhead</li>
52
+ <li><i class="sw sw-limit"></i>GPU limit</li>
53
+ </ul>
54
+ </div>
55
+ </figure>
56
+ </section>
57
+
58
+ <div class="shell workspace">
59
+ <aside class="rail" aria-label="Filters">
60
+ <form id="query" autocomplete="off">
61
+ <fieldset class="group">
62
+ <legend>Your Mac's memory</legend>
63
+ <div class="chips" id="ram-chips" role="radiogroup" aria-label="Mac memory"></div>
64
+ <div class="detect-row">
65
+ <button type="button" id="detect" class="ghost">Detect my machine</button>
66
+ <span class="hint" id="ram-hint">Or pick it above.</span>
67
+ </div>
68
+ <div id="hw-out" class="hw" hidden>
69
+ <p class="hw-estimate" id="hw-estimate"></p>
70
+ <dl class="hw-facts" id="hw-facts"></dl>
71
+ <p class="fine">A browser estimate, not an MLX benchmark. Browsers can't read unified memory size.</p>
72
+ <button type="button" id="full-bench" class="ghost small" disabled>Run 20-second GPU test</button>
73
+ <progress id="bench-progress" max="100" value="0" hidden></progress>
74
+ <p id="bench-out" class="fine" aria-live="polite"></p>
75
+ </div>
76
+ <p id="hw-error" class="note" hidden></p>
77
+ </fieldset>
78
+
79
+ <fieldset class="group">
80
+ <legend>Context length</legend>
81
+ <div class="chips tight" id="context-chips" role="radiogroup" aria-label="Context length"></div>
82
+ </fieldset>
83
+
84
+ <details class="more-filters" id="more-filters" open>
85
+ <summary>Family, size, quantization and priority</summary>
86
+ <fieldset class="group">
87
+ <legend>Model family</legend>
88
+ <div class="chips" id="family-chips" role="radiogroup" aria-label="Model family"></div>
89
+ <label class="more-family">
90
+ <span class="sr-only">More families</span>
91
+ <select id="family-more"><option value="">More families</option></select>
92
+ </label>
93
+ </fieldset>
94
+
95
+ <fieldset class="group">
96
+ <legend>Size</legend>
97
+ <div class="chips tight" id="size-chips" role="radiogroup" aria-label="Size"></div>
98
+ </fieldset>
99
+
100
+ <fieldset class="group">
101
+ <legend>Quantization</legend>
102
+ <div class="chips tight" id="quant-chips" role="radiogroup" aria-label="Quantization"></div>
103
+ </fieldset>
104
+
105
+ <fieldset class="group">
106
+ <legend>What matters most</legend>
107
+ <div class="chips" id="priority-chips" role="radiogroup" aria-label="Priority"></div>
108
+ </fieldset>
109
+
110
+ <fieldset class="group">
111
+ <label class="field">Name contains
112
+ <input type="search" id="search" maxlength="80" placeholder="coder, instruct, vl">
113
+ </label>
114
+ <label class="check"><input type="checkbox" id="non-llm"> Include speech, image and embedding models</label>
115
+ </fieldset>
116
+ </details>
117
+ </form>
118
+ </aside>
119
+
120
+ <section class="results" id="results" aria-labelledby="res-title">
121
+ <div class="results-head">
122
+ <div>
123
+ <h2 id="res-title">Models</h2>
124
+ <p id="summary" class="summary"></p>
125
+ </div>
126
+ <div class="tabs" role="tablist" aria-label="Sort" id="sort">
127
+ <button role="tab" data-sort="recommended" aria-selected="true">Best fit</button>
128
+ <button role="tab" data-sort="popular" aria-selected="false">Popular</button>
129
+ <button role="tab" data-sort="recent" aria-selected="false">Recent</button>
130
+ <button role="tab" data-sort="community" aria-selected="false">Community tested</button>
131
+ <button role="tab" data-sort="all" aria-selected="false">A–Z</button>
132
+ </div>
133
+ </div>
134
+ <div id="notices"></div>
135
+ <ol id="list" class="rows"></ol>
136
+ <button type="button" id="more" class="ghost wide" hidden>Show more models</button>
137
+ </section>
138
+ </div>
139
+
140
+ <div class="shell prose-grid">
141
+ <section id="benchmark" class="prose" aria-labelledby="bench-h">
142
+ <h2 id="bench-h">Measure a model on your Mac</h2>
143
+ <p>The GPU test above runs in your browser. This one runs MLX. The script loads a model with <code>mlx-lm</code>, measures prompt speed, generation speed, time to first token and peak memory, and prints the result. It sends nothing unless you add <code>--submit</code>, and it shows you the exact payload first.</p>
144
+ <label class="field">Model
145
+ <input id="bench-model" type="text" maxlength="200" value="mlx-community/Qwen3-0.6B-4bit" spellcheck="false">
146
+ </label>
147
+ <pre class="cmd"><code id="bench-cmd"></code></pre>
148
+ <div class="actions-row">
149
+ <button type="button" id="copy-cmd" class="ghost">Copy command</button>
150
+ <a href="/bench/mlx_explorer_bench.py" download>Download mlx_explorer_bench.py</a>
151
+ </div>
152
+ <details>
153
+ <summary>Paste a result you already ran</summary>
154
+ <textarea id="bench-json" rows="6" maxlength="4000" spellcheck="false" placeholder='{"event_type": "mlx_benchmark_submission", ...}'></textarea>
155
+ <button type="button" id="bench-submit" class="solid">Submit result</button>
156
+ <p id="bench-submit-out" class="fine" aria-live="polite"></p>
157
+ </details>
158
+ </section>
159
+
160
+ <section id="methodology" class="prose" aria-labelledby="meth-h">
161
+ <h2 id="meth-h">How it works</h2>
162
+ <details><summary>What does 4-bit mean?</summary>
163
+ <p>Quantization stores each weight with fewer bits. A 16-bit model uses about 2 bytes per parameter. A 4-bit model uses about half a byte, plus a scale and offset per group of 64 weights (roughly 4.5 bits in total). Fewer bits mean a smaller download and less memory, usually with some loss in quality that varies by model and method. <em>Mixed</em> models keep sensitive layers at higher precision and are labelled by their base width, e.g. "4-bit mixed".</p>
164
+ </details>
165
+ <details><summary>Why memory decides what runs</summary>
166
+ <p>On Apple Silicon the whole model must fit in the unified memory the GPU may use. By default macOS allows roughly two-thirds of RAM on Macs with 36 GB or less and about three-quarters on larger ones (adjustable with <code>sudo sysctl iogpu.wired_limit_mb=…</code>). A model that doesn't fit fails to load or crawls while swapping.</p>
167
+ </details>
168
+ <details><summary>Why context length matters</summary>
169
+ <p>Every token in context stores a key and a value for each attention layer: the KV cache, <code>2 × layers × kv_heads × head_dim × tokens × 2 bytes</code>. For an 8B model at 32k tokens that's about 4.5 GB, as much as its 4-bit weights. Sliding-window and linear-attention layers need far less. Weight quantization doesn't shrink the KV cache, and a model's maximum context comes from its architecture and training, not its quantization.</p>
170
+ </details>
171
+ <details><summary>What the browser GPU test measures</summary>
172
+ <p>A short WebGPU compute workload (matrix multiplications and a large buffer copy) that gives a relative score for grouping hardware. It is not MLX, not LLM inference and not tokens per second, and it varies with browser, power mode and background load.</p>
173
+ </details>
174
+ <details><summary>Where community numbers come from</summary>
175
+ <p>From people running the benchmark script on their own Macs, plus optional "how did it work" reports. Submissions are validated. Implausible values, such as generation faster than a Mac's memory bandwidth allows, are flagged and excluded from rankings.</p>
176
+ </details>
177
+ <details><summary>How "Best fit" is scored</summary>
178
+ <p>Each matching model gets a 0 to 100 score from these components, weighted by what matters most to you:</p>
179
+ <ul>
180
+ <li><strong>Fit:</strong> estimated weights (exact file sizes when available) + KV cache at your context + about 1 GB overhead, against GPU-usable memory. Comfortable under 70%, Likely under 85%, Borderline under 100%, Unlikely above.</li>
181
+ <li><strong>Quantization:</strong> quality prefers more bits, minimum memory prefers fewer, balanced prefers 4 to 6.</li>
182
+ <li><strong>Context:</strong> headroom left at your context, and whether the model's config supports that length.</li>
183
+ <li><strong>Capacity:</strong> among models that fit, more parameters usually means a more capable model. Ignored when you prioritize speed or least memory.</li>
184
+ <li><strong>Popularity</strong> (downloads and likes, log scale), <strong>recency</strong>, and <strong>community data</strong> once people contribute.</li>
185
+ </ul>
186
+ <p>The score never considers who uploaded a model or what it's called. Speed is never predicted without real measurements. Scoring version <code id="engine-version">heuristic-v1</code>. For knowledge-quality evaluations of models on MLX, see the <a href="https://huggingface.co/spaces/mlx-community/mlx-benchmark-leaderboard" target="_blank" rel="noopener">MLX Benchmark Leaderboard</a>.</p>
187
+ </details>
188
+ </section>
189
+
190
+ <section id="privacy" class="prose" aria-labelledby="priv-h">
191
+ <h2 id="priv-h">Privacy and data</h2>
192
+ <p>We collect anonymous model-selection and optional benchmark data to improve MLX Model Explorer and community recommendations. It's published as the <a id="dataset-link" href="https://huggingface.co/datasets/mlx-community/mlx-model-explorer-data" target="_blank" rel="noopener">mlx-model-explorer-data</a> dataset.</p>
193
+ <ul>
194
+ <li><strong>Collected:</strong> the filters you choose, models you open or compare, coarse hardware class (GPU vendor and architecture, core count, browser and OS family), test scores, and feedback you send.</li>
195
+ <li><strong>Never collected:</strong> name, email, IP address, location, cookies, user-agent strings or device fingerprints. Links, emails and phone numbers are stripped from notes.</li>
196
+ <li><strong>Session ID:</strong> random, lives only in this tab, and is gone when you close it.</li>
197
+ <li>The <a href="/stats">stats page</a> only shows groups of five or more.</li>
198
+ </ul>
199
+ <label class="check"><input type="checkbox" id="optout"> Don't send any data from this browser</label>
200
+ <p id="gpc-note" class="fine" hidden>Your browser sends a Global Privacy Control or Do Not Track signal, so nothing is sent unless you untick the box above.</p>
201
+ </section>
202
+ </div>
203
+ </main>
204
+
205
+ <footer class="shell foot">
206
+ <span>A community tool for <a href="https://github.com/ml-explore/mlx" target="_blank" rel="noopener">MLX</a> on Apple Silicon.</span>
207
+ <span id="cat-status"></span>
208
+ <span>Version <span id="app-version"></span></span>
209
+ </footer>
210
+
211
+ <div id="compare-tray" class="tray" hidden>
212
+ <span id="compare-count"></span>
213
+ <button type="button" id="compare-open" class="solid">Compare</button>
214
+ <button type="button" id="compare-clear" class="ghost">Clear</button>
215
+ </div>
216
+
217
+ <dialog id="detail" class="sheet" aria-labelledby="d-title">
218
+ <div class="sheet-head">
219
+ <h2 id="d-title"></h2>
220
+ <button type="button" class="close" data-close aria-label="Close">×</button>
221
+ </div>
222
+ <div id="d-body" class="sheet-body"></div>
223
+ </dialog>
224
+
225
+ <dialog id="compare" class="modal" aria-labelledby="c-title">
226
+ <div class="sheet-head">
227
+ <h2 id="c-title">Compare models</h2>
228
+ <button type="button" class="close" data-close aria-label="Close">×</button>
229
+ </div>
230
+ <div id="c-body" class="sheet-body"></div>
231
+ </dialog>
232
+
233
+ <template id="feedback-tpl">
234
+ <form class="feedback">
235
+ <h3>Have you tried this model?</h3>
236
+ <div class="chips">
237
+ <label class="chip"><input type="radio" name="tried" value="yes"><span>Yes</span></label>
238
+ <label class="chip"><input type="radio" name="tried" value="no"><span>No</span></label>
239
+ <label class="chip"><input type="radio" name="tried" value="planning"><span>Planning to</span></label>
240
+ </div>
241
+ <div class="fb-yes" hidden>
242
+ <h4>How did it work?</h4>
243
+ <div class="chips">
244
+ <label class="chip"><input type="radio" name="outcome" value="q:excellent"><span>Excellent</span></label>
245
+ <label class="chip"><input type="radio" name="outcome" value="q:good"><span>Good</span></label>
246
+ <label class="chip"><input type="radio" name="outcome" value="q:acceptable"><span>Acceptable</span></label>
247
+ <label class="chip"><input type="radio" name="outcome" value="f:too_slow"><span>Too slow</span></label>
248
+ <label class="chip"><input type="radio" name="outcome" value="f:too_much_memory"><span>Too much memory</span></label>
249
+ <label class="chip"><input type="radio" name="outcome" value="f:low_quality"><span>Quality wasn't good enough</span></label>
250
+ <label class="chip"><input type="radio" name="outcome" value="f:didnt_run"><span>Didn't run</span></label>
251
+ </div>
252
+ <div class="fb-grid">
253
+ <label class="field">Mac memory<select name="ram"><option value="">Optional</option></select></label>
254
+ <label class="field">Chip<select name="chip"><option value="">Optional</option></select></label>
255
+ <label class="field">Tokens per second<input name="tps" type="number" min="0.1" max="2000" step="0.1" inputmode="decimal" placeholder="Optional"></label>
256
+ <label class="field">Context used<select name="ctx"><option value="">Optional</option></select></label>
257
+ </div>
258
+ <label class="field">Notes, up to 280 characters, no personal info
259
+ <textarea name="notes" rows="2" maxlength="280"></textarea>
260
+ </label>
261
+ </div>
262
+ <div class="actions-row">
263
+ <button type="submit" class="solid">Send report</button>
264
+ <span class="fb-out fine" aria-live="polite"></span>
265
+ </div>
266
+ </form>
267
+ </template>
268
+
269
+ <script src="/static/webgpu.js"></script>
270
+ <script src="/static/app.js"></script>
271
+ </body>
272
+ </html>
static/stats.html ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Community stats · MLX Model Explorer</title>
7
+ <meta name="color-scheme" content="light dark">
8
+ <link rel="stylesheet" href="/static/styles.css">
9
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317212B'/%3E%3Crect x='6' y='13' width='11' height='6' rx='1.5' fill='%234F86C6'/%3E%3Crect x='17' y='13' width='5' height='6' rx='1.5' fill='%239C7FD6'/%3E%3Crect x='24.5' y='9' width='1.5' height='14' rx='.75' fill='%23fff'/%3E%3C/svg%3E">
10
+ </head>
11
+ <body>
12
+ <header class="top">
13
+ <div class="shell top-row">
14
+ <a class="brand" href="/">
15
+ <svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="12" width="14" height="8" rx="2" class="fill-w"/><rect x="17" y="12" width="6" height="8" rx="2" class="fill-kv"/><rect x="26" y="7" width="2" height="18" rx="1" class="fill-limit"/></svg>
16
+ <span>MLX Model Explorer</span>
17
+ </a>
18
+ <nav aria-label="Sections">
19
+ <a href="/">Explorer</a>
20
+ <a id="ds" href="https://huggingface.co/datasets/mlx-community/mlx-model-explorer-data" target="_blank" rel="noopener">Dataset</a>
21
+ </nav>
22
+ </div>
23
+ </header>
24
+ <main class="shell">
25
+ <section class="stats-hero">
26
+ <h1>What the MLX community is trying to run</h1>
27
+ <p class="lede">Anonymous totals from MLX Model Explorer. Groups smaller than <span id="kmin">5</span> are combined into "other", flagged submissions are left out, and no individual record is shown.</p>
28
+ </section>
29
+ <div class="totals" id="totals"></div>
30
+ <section class="chart" aria-labelledby="q-h">
31
+ <h2 id="q-h">Quantization people search for</h2>
32
+ <div class="qshare" id="qshare" role="img" aria-label="Quantization share"></div>
33
+ <p class="scale-note" id="qshare-note"></p>
34
+ <p class="scale-note" id="avgctx"></p>
35
+ </section>
36
+ <div class="charts" id="charts"></div>
37
+ <p class="scale-note" id="updated"></p>
38
+ </main>
39
+ <footer class="shell foot"><span>MLX Model Explorer</span></footer>
40
+ <script src="/static/stats.js"></script>
41
+ </body>
42
+ </html>
static/stats.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
+ (function () {
4
+ const $ = (s) => document.querySelector(s);
5
+ function el(tag, cls, text) {
6
+ const n = document.createElement(tag);
7
+ if (cls) n.className = cls;
8
+ if (text != null) n.textContent = text;
9
+ return n;
10
+ }
11
+ const LABELS = {
12
+ events: "Events", sessions: "Sessions", searches: "Searches", distinct_queries: "Distinct queries", model_views: "Models opened",
13
+ model_clicks: "Hugging Face visits", hardware_tests: "Hardware checks", browser_benchmarks: "GPU tests",
14
+ feedback: "Reports", mlx_benchmarks: "MLX benchmarks",
15
+ };
16
+ const CHARTS = [
17
+ ["families", "Model families"], ["sizes", "Model sizes"], ["ram_classes", "Mac memory (GB)"],
18
+ ["contexts", "Target context"], ["priorities", "What matters most"], ["selected_models", "Most compared and visited models"],
19
+ ["gpu_capability", "Browser GPU class"], ["webgpu_available", "WebGPU available"],
20
+ ];
21
+ const QCOLORS = { "2-bit": "#8a4f9e", "3-bit": "#7a5ab8", "4-bit": "#2f5d8c", "5-bit": "#3f7f86", "6-bit": "#2e7d4f", "8-bit": "#62802a" };
22
+ const fmtKey = (k) => (k === true ? "yes" : k === false ? "no" : typeof k === "number" && k >= 4096 ? `${Math.round(k / 1024)}k` : String(k));
23
+
24
+ function bars(items) {
25
+ const box = el("div", "bars");
26
+ if (!items.length) {
27
+ box.append(el("p", "scale-note", "Not enough data yet."));
28
+ return box;
29
+ }
30
+ const max = Math.max(...items.map((x) => x.count));
31
+ for (const it of items) {
32
+ const row = el("div", "bar");
33
+ const track = el("div", "track");
34
+ const fill = el("div", "fill");
35
+ fill.style.width = `${(it.count / max) * 100}%`;
36
+ track.append(fill);
37
+ const key = fmtKey(it.key);
38
+ const k = el("span", "k", key);
39
+ k.title = key;
40
+ row.append(k, track, el("span", "n", `${it.count.toLocaleString()} (${Math.round(it.share * 100)}%)`));
41
+ box.append(row);
42
+ }
43
+ return box;
44
+ }
45
+
46
+ async function load() {
47
+ let s;
48
+ try {
49
+ const r = await fetch("/api/stats");
50
+ if (!r.ok) throw new Error(String(r.status));
51
+ s = await r.json();
52
+ } catch (e) {
53
+ $("#updated").textContent = "Statistics couldn't be loaded. Refresh in a moment.";
54
+ return;
55
+ }
56
+ $("#kmin").textContent = s.k_min;
57
+ $("#totals").replaceChildren(...Object.entries(s.totals).map(([k, v]) => {
58
+ const d = el("div", "total");
59
+ d.append(el("b", null, v.toLocaleString()), el("span", null, LABELS[k] || k));
60
+ return d;
61
+ }));
62
+ const q = Object.entries(s.quant_share).filter(([, v]) => v);
63
+ const qs = $("#qshare");
64
+ qs.replaceChildren(...q.map(([k, v]) => {
65
+ const seg = el("span", null, v >= 0.08 ? `${k} ${Math.round(v * 100)}%` : "");
66
+ seg.style.width = `${v * 100}%`;
67
+ seg.style.background = QCOLORS[k] || "#7b8895";
68
+ seg.title = `${k}: ${Math.round(v * 100)}%`;
69
+ return seg;
70
+ }));
71
+ $("#qshare-note").textContent = q.length ? q.map(([k, v]) => `${k} ${Math.round(v * 100)}%`).join(", ") : "Not enough searches with a quantization filter yet.";
72
+ $("#avgctx").textContent = s.average_target_context ? `Average target context: ${Math.round(s.average_target_context / 1024)}k tokens.` : "";
73
+ $("#charts").replaceChildren(...CHARTS.map(([key, title]) => {
74
+ const c = el("section", "chart");
75
+ c.append(el("h2", null, title), bars(s[key] || []));
76
+ return c;
77
+ }));
78
+ $("#updated").textContent = `Updated ${new Date().toLocaleString()}.`;
79
+ }
80
+ load();
81
+ })();
static/styles.css ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* MLX Model Explorer
2
+ One idea carries the page: the memory budget. Everything else stays quiet. */
3
+
4
+ :root {
5
+ --ground: #f2f4f6;
6
+ --surface: #ffffff;
7
+ --sunk: #e8ecf0;
8
+ --ink: #17212b;
9
+ --ink-2: #4a5866;
10
+ --ink-3: #7b8895;
11
+ --line: #d9dfe5;
12
+ --focus: #2f5d8c;
13
+
14
+ --weights: #2f5d8c;
15
+ --kv: #7a5ab8;
16
+ --overhead: #a3adb7;
17
+ --limit: #17212b;
18
+
19
+ --fit-comfortable: #2e7d4f;
20
+ --fit-likely: #62802a;
21
+ --fit-borderline: #b7791f;
22
+ --fit-unlikely: #b23a2e;
23
+
24
+ --display: ui-rounded, "SF Pro Rounded", "Nunito", system-ui, -apple-system, "Segoe UI", sans-serif;
25
+ --text: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
26
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
27
+
28
+ --r-lg: 18px;
29
+ --r-md: 10px;
30
+ --r-sm: 6px;
31
+ color-scheme: light;
32
+ }
33
+
34
+ @media (prefers-color-scheme: dark) {
35
+ :root {
36
+ --ground: #10161c;
37
+ --surface: #17202a;
38
+ --sunk: #1f2a35;
39
+ --ink: #e8edf2;
40
+ --ink-2: #b2bdc8;
41
+ --ink-3: #82909d;
42
+ --line: #2a3643;
43
+ --focus: #8fb6e6;
44
+ --weights: #4f86c6;
45
+ --kv: #9c7fd6;
46
+ --overhead: #5d6b78;
47
+ --limit: #e8edf2;
48
+ --fit-comfortable: #5fbf85;
49
+ --fit-likely: #a3c45a;
50
+ --fit-borderline: #e0a84a;
51
+ --fit-unlikely: #ec7a6c;
52
+ color-scheme: dark;
53
+ }
54
+ }
55
+
56
+ *, *::before, *::after { box-sizing: border-box; }
57
+ html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
58
+ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { transition: none !important; animation: none !important; } }
59
+ body { margin: 0; background: var(--ground); color: var(--ink); font: 15px/1.55 var(--text); font-variant-numeric: tabular-nums; }
60
+ a { color: inherit; text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); text-underline-offset: 3px; }
61
+ a:hover { text-decoration-color: currentColor; }
62
+ code, pre { font-family: var(--mono); font-size: 0.86em; }
63
+ :not(pre) > code { background: var(--sunk); padding: 1px 5px; border-radius: 4px; }
64
+ [hidden] { display: none !important; }
65
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
66
+ .skip { position: absolute; left: -9999px; }
67
+ .skip:focus { left: 12px; top: 12px; z-index: 50; background: var(--surface); padding: 8px 12px; border-radius: var(--r-sm); }
68
+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
69
+
70
+ .shell { width: 100%; max-width: 1240px; margin-inline: auto; padding-inline: 20px; }
71
+
72
+ /* ---------------------------------------------------------------- header */
73
+ .top { border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--ground) 85%, transparent);
74
+ position: sticky; top: 0; z-index: 20; backdrop-filter: saturate(1.4) blur(10px); -webkit-backdrop-filter: saturate(1.4) blur(10px); }
75
+ .top-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 56px; flex-wrap: wrap; }
76
+ .brand { display: inline-flex; align-items: center; gap: 10px; font: 600 16px/1 var(--display); text-decoration: none; letter-spacing: -0.01em; }
77
+ .brand-mark { width: 26px; height: 26px; }
78
+ .fill-w { fill: var(--weights); } .fill-kv { fill: var(--kv); } .fill-limit { fill: var(--limit); }
79
+ .top nav { display: flex; gap: 18px; flex-wrap: wrap; font-size: 14px; }
80
+ .top nav a { text-decoration: none; color: var(--ink-2); }
81
+ .top nav a:hover { color: var(--ink); }
82
+
83
+ /* ---------------------------------------------------------------- hero: the memory budget */
84
+ .hero { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 40px; align-items: end; padding-block: 44px 28px; }
85
+ h1 { font: 600 clamp(30px, 4.2vw, 46px)/1.08 var(--display); letter-spacing: -0.025em; margin: 0 0 14px; max-width: 14ch; }
86
+ .lede { color: var(--ink-2); margin: 0; max-width: 46ch; font-size: 16px; }
87
+ .lede a { color: var(--ink); }
88
+
89
+ .budget { margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 20px 22px 18px; }
90
+ .budget-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
91
+ .budget-mac { font: 600 20px/1.2 var(--display); letter-spacing: -0.01em; }
92
+ .budget-usable { color: var(--ink-3); font-size: 14px; }
93
+ .budget-track { position: relative; height: 46px; border-radius: 12px; background:
94
+ repeating-linear-gradient(90deg, transparent 0 calc(12.5% - 1px), color-mix(in srgb, var(--line) 70%, transparent) calc(12.5% - 1px) 12.5%),
95
+ var(--sunk);
96
+ display: flex; overflow: hidden; }
97
+ .seg { display: block; height: 100%; width: 0; transition: width 420ms cubic-bezier(.2, .8, .2, 1); }
98
+ .seg-w { background: var(--weights); }
99
+ .seg-kv { background: var(--kv); }
100
+ .seg-oh { background: var(--overhead); }
101
+ .budget-track.over .seg-oh { background: repeating-linear-gradient(135deg, var(--fit-unlikely) 0 6px, color-mix(in srgb, var(--fit-unlikely) 70%, transparent) 6px 12px); }
102
+ .limit { position: absolute; top: -6px; bottom: -6px; width: 3px; margin-left: -1.5px; background: var(--limit); border-radius: 2px;
103
+ transition: left 420ms cubic-bezier(.2, .8, .2, 1); }
104
+ .budget-foot { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px 20px; flex-wrap: wrap; margin-top: 14px; }
105
+ .budget-model { margin: 0; font-size: 14px; color: var(--ink-2); max-width: 58ch; }
106
+ .budget-model strong { color: var(--ink); font-weight: 600; }
107
+ .legend { list-style: none; margin: 0; padding: 0; display: flex; gap: 14px; flex-wrap: wrap; font-size: 12.5px; color: var(--ink-3); }
108
+ .legend li { display: inline-flex; align-items: center; gap: 6px; }
109
+ .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; }
110
+ .sw-limit { width: 3px; height: 13px; border-radius: 2px; background: var(--limit); }
111
+
112
+ /* ---------------------------------------------------------------- workspace */
113
+ .workspace { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 32px; align-items: start; padding-bottom: 56px; }
114
+ .rail { position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-right: 6px; scrollbar-width: thin; }
115
+ .group { border: 0; margin: 0 0 22px; padding: 0; min-width: 0; }
116
+ .group legend { font: 600 14px/1.3 var(--display); padding: 0; margin-bottom: 9px; }
117
+
118
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
119
+ .chip, .chips > button { position: relative; display: inline-flex; }
120
+ .chip input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
121
+ .chip span, .chips > button {
122
+ display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 5px 11px; border-radius: 999px;
123
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: 500 13px/1.2 var(--text);
124
+ cursor: pointer; user-select: none; white-space: nowrap;
125
+ }
126
+ .chips.tight .chip span { padding-inline: 9px; }
127
+ .chip span small { color: var(--ink-3); font-size: 11.5px; font-weight: 400; }
128
+ .chip:hover span, .chips > button:hover { border-color: var(--ink-3); color: var(--ink); }
129
+ .chip input:checked + span, .chips > button[aria-pressed="true"] { background: var(--ink); border-color: var(--ink); color: var(--surface); }
130
+ .chip input:checked + span small { color: color-mix(in srgb, var(--surface) 70%, transparent); }
131
+ .chip input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }
132
+
133
+ .more-family { display: block; margin-top: 8px; }
134
+ .more-filters > summary { display: none; }
135
+ details.more-filters { border: 0; padding: 0; }
136
+ .more-filters > *:not(summary) { margin-top: 0; }
137
+ select, input[type="search"], input[type="text"], input[type="number"], textarea {
138
+ width: 100%; font: 400 14px/1.3 var(--text); color: var(--ink); background: var(--surface);
139
+ border: 1px solid var(--line); border-radius: var(--r-sm); padding: 8px 10px; min-height: 36px;
140
+ }
141
+ textarea { resize: vertical; font-family: var(--mono); font-size: 13px; }
142
+ .field { display: flex; flex-direction: column; gap: 5px; font-size: 13px; font-weight: 500; color: var(--ink-2); margin-bottom: 10px; }
143
+ .check { display: flex; align-items: flex-start; gap: 8px; font-size: 13.5px; color: var(--ink-2); cursor: pointer; }
144
+ .check input { margin-top: 3px; accent-color: var(--ink); }
145
+
146
+ button { font: inherit; color: inherit; }
147
+ .ghost, .solid { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 6px 13px;
148
+ border-radius: var(--r-sm); font: 500 13.5px/1.2 var(--text); cursor: pointer; }
149
+ .ghost { background: transparent; border: 1px solid var(--line); color: var(--ink); }
150
+ .ghost:hover:not(:disabled) { border-color: var(--ink-3); background: var(--surface); }
151
+ .solid { background: var(--ink); border: 1px solid var(--ink); color: var(--surface); }
152
+ .solid:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 85%, var(--weights)); }
153
+ button:disabled { opacity: .45; cursor: default; }
154
+ .small { min-height: 30px; font-size: 12.5px; padding: 4px 10px; }
155
+ .wide { width: 100%; margin-top: 14px; }
156
+
157
+ .detect-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
158
+ .hint, .fine { font-size: 12.5px; color: var(--ink-3); margin: 0; }
159
+ .hw { margin-top: 12px; padding: 12px; border-radius: var(--r-md); background: var(--sunk); }
160
+ .hw-estimate { margin: 0 0 8px; font: 600 14px/1.35 var(--display); }
161
+ .hw-facts { display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; margin: 0 0 8px; font-size: 12.5px; }
162
+ .hw-facts dt { color: var(--ink-3); }
163
+ .hw-facts dd { margin: 0; }
164
+ .hw .fine { margin-bottom: 8px; }
165
+ progress { display: block; width: 100%; height: 6px; margin-top: 8px; accent-color: var(--weights); }
166
+ .note { font-size: 13px; color: var(--ink); background: color-mix(in srgb, var(--fit-borderline) 14%, var(--surface));
167
+ border-radius: var(--r-sm); padding: 8px 10px; margin: 10px 0 0; }
168
+
169
+ /* ---------------------------------------------------------------- results */
170
+ .results-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px 20px; flex-wrap: wrap; margin-bottom: 10px; }
171
+ .results-head h2 { font: 600 22px/1.2 var(--display); margin: 0; letter-spacing: -0.01em; }
172
+ .summary { margin: 2px 0 0; color: var(--ink-3); font-size: 13.5px; }
173
+ .tabs { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; border-radius: 10px; background: var(--sunk); }
174
+ .tabs button { border: 0; background: transparent; color: var(--ink-2); padding: 6px 11px; border-radius: 7px; font-size: 13px; cursor: pointer; min-height: 30px; }
175
+ .tabs button[aria-selected="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(23, 33, 43, .12); font-weight: 600; }
176
+ #notices .note { margin: 0 0 8px; }
177
+
178
+ .rows { list-style: none; margin: 8px 0 0; padding: 0; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; }
179
+ .rows:empty { display: none; }
180
+ .row { display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 6px 22px; padding: 14px 18px; border-top: 1px solid var(--line); }
181
+ .row:first-child { border-top: 0; }
182
+ .row.active { background: color-mix(in srgb, var(--weights) 5%, var(--surface)); }
183
+ .row-main { min-width: 0; }
184
+ .row-title { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
185
+ .row-name { background: none; border: 0; padding: 0; margin: 0; cursor: pointer; text-align: left; font: 600 15.5px/1.3 var(--text);
186
+ color: var(--ink); word-break: break-word; }
187
+ .row-name:hover { text-decoration: underline; text-underline-offset: 3px; }
188
+ .row-org { color: var(--ink-3); font-weight: 400; }
189
+ .facts { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 5px 0 0; font-size: 13px; color: var(--ink-2); }
190
+ .facts .tag { color: var(--ink); font-weight: 500; }
191
+ .facts .warn { color: var(--fit-borderline); }
192
+ .why { margin: 7px 0 0; font-size: 13px; color: var(--ink-3); max-width: 72ch; }
193
+ .row-actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: 13px; }
194
+ .row-actions .check { font-size: 13px; align-items: center; }
195
+ .row-actions .check input { margin-top: 0; }
196
+ .row-actions a, .linkish { color: var(--ink-2); }
197
+ .linkish { background: none; border: 0; padding: 0; cursor: pointer; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); text-underline-offset: 3px; font-size: 13px; }
198
+
199
+ .row-fit { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
200
+ .fit-line { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 13px; }
201
+ .fit-gb { font: 600 17px/1 var(--display); color: var(--ink); }
202
+ .fit-label { font-weight: 600; font-size: 12.5px; }
203
+ .fit-Comfortable { color: var(--fit-comfortable); }
204
+ .fit-Likely { color: var(--fit-likely); }
205
+ .fit-Borderline { color: var(--fit-borderline); }
206
+ .fit-Unlikely { color: var(--fit-unlikely); }
207
+ .fit-none { color: var(--ink-3); font-weight: 400; }
208
+ .score { font-size: 12px; color: var(--ink-3); }
209
+
210
+ .mini { position: relative; display: flex; height: 10px; border-radius: 5px; background: var(--sunk); overflow: visible; }
211
+ .mini .seg:first-child { border-radius: 5px 0 0 5px; }
212
+ .mini .seg { transition: none; }
213
+ .mini .limit { top: -3px; bottom: -3px; width: 2px; margin-left: -1px; transition: none; }
214
+ .mini.over .seg-oh { background: var(--fit-unlikely); }
215
+
216
+ /* ---------------------------------------------------------------- prose sections */
217
+ .prose-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px; padding-block: 44px 20px; border-top: 1px solid var(--line); }
218
+ .prose h2 { font: 600 20px/1.25 var(--display); margin: 0 0 10px; letter-spacing: -0.01em; }
219
+ .prose p, .prose li { font-size: 14px; color: var(--ink-2); max-width: 70ch; }
220
+ .prose ul { padding-left: 18px; }
221
+ .prose strong { color: var(--ink); }
222
+ details { border-top: 1px solid var(--line); padding: 10px 0; }
223
+ details:last-of-type { border-bottom: 1px solid var(--line); }
224
+ summary { cursor: pointer; font-weight: 600; font-size: 14px; list-style: none; display: flex; justify-content: space-between; gap: 10px; }
225
+ summary::-webkit-details-marker { display: none; }
226
+ summary::after { content: "+"; color: var(--ink-3); font-weight: 400; }
227
+ details[open] summary::after { content: "–"; }
228
+ details > *:not(summary) { margin-top: 8px; }
229
+ pre.cmd { background: var(--ink); color: var(--ground); padding: 14px 16px; border-radius: var(--r-md); overflow-x: auto; line-height: 1.6; margin: 10px 0; }
230
+ @media (prefers-color-scheme: dark) { pre.cmd { background: #0a0f14; color: var(--ink); border: 1px solid var(--line); } }
231
+ .actions-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 8px; font-size: 13.5px; }
232
+
233
+ .foot { display: flex; flex-wrap: wrap; gap: 6px 22px; padding-block: 18px 90px; font-size: 13px; color: var(--ink-3); border-top: 1px solid var(--line); }
234
+
235
+ /* ---------------------------------------------------------------- compare tray */
236
+ .tray { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: 10px;
237
+ padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--ink); color: var(--surface); box-shadow: 0 8px 28px rgba(10, 20, 30, .28);
238
+ max-width: calc(100% - 32px); font-size: 14px; }
239
+ .tray .ghost { color: var(--surface); border-color: color-mix(in srgb, var(--surface) 30%, transparent); }
240
+ .tray .ghost:hover { background: transparent; border-color: var(--surface); }
241
+ .tray .solid { background: var(--surface); color: var(--ink); border-color: var(--surface); }
242
+
243
+ /* ---------------------------------------------------------------- dialogs */
244
+ dialog { border: 0; padding: 0; color: var(--ink); background: var(--surface); }
245
+ dialog::backdrop { background: rgba(12, 18, 24, .45); }
246
+ dialog.sheet { margin: 0 0 0 auto; width: min(640px, 100vw); height: 100vh; max-height: 100vh; border-left: 1px solid var(--line); }
247
+ dialog.sheet[open] { animation: sheet-in 260ms cubic-bezier(.2, .8, .2, 1); }
248
+ @keyframes sheet-in { from { transform: translateX(24px); opacity: .6; } to { transform: none; opacity: 1; } }
249
+ dialog.modal { width: min(980px, calc(100vw - 24px)); max-height: calc(100vh - 48px); border-radius: var(--r-lg); border: 1px solid var(--line); }
250
+ .sheet-head { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 12px;
251
+ padding: 14px 20px; background: var(--surface); border-bottom: 1px solid var(--line); }
252
+ .sheet-head h2 { margin: 0; font: 600 17px/1.3 var(--display); word-break: break-word; }
253
+ .close { border: 1px solid var(--line); background: transparent; width: 34px; height: 34px; border-radius: 8px; font-size: 20px; line-height: 1; cursor: pointer; flex: none; }
254
+ .sheet-body { padding: 18px 20px 28px; }
255
+ .sheet-body h3 { font: 600 15px/1.3 var(--display); margin: 24px 0 10px; }
256
+ .sheet-body h4 { font-size: 13.5px; margin: 14px 0 8px; }
257
+ .sheet-lede { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 13.5px; color: var(--ink-2); margin: 0 0 12px; }
258
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13.5px; margin: 0; }
259
+ .kv dt { color: var(--ink-3); }
260
+ .kv dd { margin: 0; word-break: break-word; }
261
+ .why-list { margin: 0; padding-left: 18px; font-size: 13.5px; color: var(--ink-2); }
262
+ .why-list li + li { margin-top: 4px; }
263
+
264
+ .ctx-bars { display: grid; grid-template-columns: 44px minmax(0, 1fr) 64px 92px; gap: 9px 12px; align-items: center; font-size: 13px; }
265
+ .ctx-bars .ctx { color: var(--ink-3); }
266
+ .ctx-bars .cur { color: var(--ink); font-weight: 600; }
267
+ .ctx-bars .gb { text-align: right; font-weight: 500; }
268
+ .ctx-bars .mini { height: 12px; }
269
+ .scale-note { font-size: 12.5px; color: var(--ink-3); margin: 10px 0 0; }
270
+
271
+ .table-scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--r-md); }
272
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
273
+ th, td { text-align: left; padding: 8px 12px; border-top: 1px solid var(--line); vertical-align: top; }
274
+ thead th { border-top: 0; color: var(--ink-3); font-weight: 500; white-space: nowrap; background: var(--sunk); }
275
+ tbody th { color: var(--ink-3); font-weight: 500; white-space: nowrap; }
276
+ td.num { text-align: right; }
277
+ .compare-bar td .mini { min-width: 140px; }
278
+
279
+ .feedback { margin-top: 26px; padding-top: 4px; border-top: 1px solid var(--line); }
280
+ .fb-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; margin-top: 12px; }
281
+ .feedback .chips { margin-bottom: 4px; }
282
+
283
+ /* ---------------------------------------------------------------- stats page */
284
+ .stats-hero { padding-block: 40px 24px; }
285
+ .stats-hero h1 { max-width: 20ch; }
286
+ .totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; margin-bottom: 28px; }
287
+ .total { padding: 14px 16px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); margin-right: -1px; margin-bottom: -1px; }
288
+ .total b { display: block; font: 600 26px/1.1 var(--display); }
289
+ .total span { font-size: 12.5px; color: var(--ink-3); }
290
+ .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 28px 40px; padding-bottom: 40px; }
291
+ .chart h2 { font: 600 16px/1.3 var(--display); margin: 0 0 10px; }
292
+ .bars { display: grid; gap: 6px; }
293
+ .bar { display: grid; grid-template-columns: minmax(80px, 38%) minmax(0, 1fr) 78px; gap: 10px; align-items: center; font-size: 13px; }
294
+ .bar .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
295
+ .bar .track { height: 10px; border-radius: 5px; background: var(--sunk); overflow: hidden; }
296
+ .bar .fill { height: 100%; background: var(--weights); border-radius: 5px; }
297
+ .bar .n { text-align: right; color: var(--ink-3); }
298
+ .qshare { display: flex; height: 36px; border-radius: 10px; overflow: hidden; background: var(--sunk); margin: 6px 0 8px; }
299
+ .qshare span { display: flex; align-items: center; justify-content: center; font-size: 12px; color: #fff; min-width: 0; overflow: hidden; white-space: nowrap; }
300
+
301
+ /* ---------------------------------------------------------------- responsive */
302
+ @media (max-width: 1080px) {
303
+ .prose-grid { grid-template-columns: minmax(0, 1fr); gap: 28px; }
304
+ }
305
+ @media (max-width: 900px) {
306
+ .hero { grid-template-columns: 1fr; gap: 24px; padding-top: 28px; }
307
+ h1 { max-width: none; }
308
+ .workspace { grid-template-columns: minmax(0, 1fr); gap: 20px; }
309
+ .rail { position: static; max-height: none; overflow: visible; padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); }
310
+ .group { margin-bottom: 16px; }
311
+ .more-filters { border-top: 1px solid var(--line); padding-top: 10px; }
312
+ .more-filters > summary { display: flex; margin-bottom: 4px; }
313
+ .more-filters[open] > summary { margin-bottom: 14px; }
314
+ }
315
+ @media (max-width: 600px) {
316
+ .shell { padding-inline: 16px; }
317
+ .top nav { gap: 12px; font-size: 13px; }
318
+ .budget { padding: 16px; }
319
+ .budget-track { height: 38px; }
320
+ .row { grid-template-columns: 1fr; padding: 14px; }
321
+ .row-fit { order: -1; }
322
+ .ctx-bars { grid-template-columns: 40px minmax(0, 1fr) 58px; }
323
+ .ctx-bars .fitc { grid-column: 2 / -1; margin-top: -6px; }
324
+ .fb-grid { grid-template-columns: 1fr; }
325
+ .tabs { width: 100%; }
326
+ }
static/webgpu.js ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Browser hardware estimate via WebGPU. This is NOT MLX and NOT LLM inference:
2
+ // it measures relative GPU compute in this browser, for coarse hardware grouping.
3
+ "use strict";
4
+
5
+ (function () {
6
+ const BENCH_VERSION = "webgpu-1";
7
+
8
+ // Reference throughputs that map to a score of 1000 in each phase. They are
9
+ // fixed constants so scores stay comparable across versions of this file.
10
+ const REF = { mm256: 30, mm512: 30, mm1024: 30, copy: 20 }; // GFLOPS, GFLOPS, GFLOPS, GB/s
11
+
12
+ const MATMUL_WGSL = `
13
+ struct Dims { n : u32 }
14
+ @group(0) @binding(0) var<storage, read> a : array<f32>;
15
+ @group(0) @binding(1) var<storage, read> b : array<f32>;
16
+ @group(0) @binding(2) var<storage, read_write> c : array<f32>;
17
+ @group(0) @binding(3) var<uniform> dims : Dims;
18
+ @compute @workgroup_size(16, 16)
19
+ fn main(@builtin(global_invocation_id) id : vec3<u32>) {
20
+ let n = dims.n;
21
+ if (id.x >= n || id.y >= n) { return; }
22
+ var s = 0.0;
23
+ for (var k = 0u; k < n; k = k + 1u) {
24
+ s = s + a[id.y * n + k] * b[k * n + id.x];
25
+ }
26
+ c[id.y * n + id.x] = s;
27
+ }`;
28
+
29
+ function browserFamily() {
30
+ const ua = navigator.userAgent;
31
+ if (/Edg\//.test(ua)) return "edge";
32
+ if (/Firefox\//.test(ua)) return "firefox";
33
+ if (/Chrome\//.test(ua)) return "chrome";
34
+ if (/Safari\//.test(ua)) return "safari";
35
+ return "other";
36
+ }
37
+
38
+ function osFamily() {
39
+ const p = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
40
+ const ua = navigator.userAgent;
41
+ if (/iPhone|iPad|iPod/.test(ua)) return "ios";
42
+ if (/Mac/i.test(p)) return navigator.maxTouchPoints > 1 ? "ios" : "macos";
43
+ if (/Win/i.test(p)) return "windows";
44
+ if (/Android/i.test(ua)) return "android";
45
+ if (/Linux/i.test(p)) return "linux";
46
+ return "other";
47
+ }
48
+
49
+ // Keep only short, generic identifiers ("apple", "metal-3"), never full descriptions.
50
+ function token(s) {
51
+ return (typeof s === "string" && /^[A-Za-z0-9 ._+-]{1,24}$/.test(s)) ? s.toLowerCase() : null;
52
+ }
53
+
54
+ function memoryPrior(deviceMemory) {
55
+ // Chrome reports RAM rounded DOWN to a power of two and capped (currently 32).
56
+ // So 16 means "16 to 31 GB" and 32 means "32 GB or more".
57
+ if (!deviceMemory) return null;
58
+ if (deviceMemory >= 32) return { ram: 32, label: "32 GB+ class", note: "your browser reports at least 32 GB" };
59
+ if (deviceMemory >= 16) return { ram: 16, label: "16 GB-class (16 to 31 GB)", note: "your browser reports 16 GB or more" };
60
+ if (deviceMemory >= 8) return { ram: 8, label: "8 GB-class (8 to 15 GB)", note: "your browser reports 8 GB or more" };
61
+ return { ram: 8, label: "8 GB-class or less", note: "your browser reports under 8 GB" };
62
+ }
63
+
64
+ function capabilityClass(quickScore) {
65
+ if (quickScore == null) return "unknown";
66
+ if (quickScore >= 4000) return "high";
67
+ if (quickScore >= 1500) return "mid";
68
+ return "entry";
69
+ }
70
+
71
+ const CLASS_LABEL = {
72
+ high: "Higher-tier GPU compute",
73
+ mid: "Mid-tier GPU compute",
74
+ entry: "Entry-level GPU compute",
75
+ unknown: "GPU compute unknown",
76
+ };
77
+
78
+ async function getDevice() {
79
+ if (!("gpu" in navigator) || !navigator.gpu) return { error: "unavailable" };
80
+ let adapter;
81
+ try {
82
+ adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
83
+ } catch (e) {
84
+ return { error: "unavailable" };
85
+ }
86
+ if (!adapter) return { error: "unavailable" };
87
+ try {
88
+ const device = await adapter.requestDevice();
89
+ device.lost.then(() => {});
90
+ return { adapter, device };
91
+ } catch (e) {
92
+ return { error: "device", adapter };
93
+ }
94
+ }
95
+
96
+ function makeMatmul(device, n) {
97
+ const size = n * n * 4;
98
+ const mk = (usage) => device.createBuffer({ size, usage });
99
+ const a = mk(GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
100
+ const b = mk(GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
101
+ const c = mk(GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
102
+ const u = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
103
+ const data = new Float32Array(n * n);
104
+ for (let i = 0; i < data.length; i++) data[i] = ((i * 2654435761) % 1000) / 1000;
105
+ device.queue.writeBuffer(a, 0, data);
106
+ device.queue.writeBuffer(b, 0, data);
107
+ device.queue.writeBuffer(u, 0, new Uint32Array([n]));
108
+ const module = device.createShaderModule({ code: MATMUL_WGSL });
109
+ const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
110
+ const bind = device.createBindGroup({
111
+ layout: pipeline.getBindGroupLayout(0),
112
+ entries: [a, b, c, u].map((buffer, i) => ({ binding: i, resource: { buffer } })),
113
+ });
114
+ const groups = Math.ceil(n / 16);
115
+ return {
116
+ async run(passes) {
117
+ const enc = device.createCommandEncoder();
118
+ for (let p = 0; p < passes; p++) {
119
+ const pass = enc.beginComputePass();
120
+ pass.setPipeline(pipeline);
121
+ pass.setBindGroup(0, bind);
122
+ pass.dispatchWorkgroups(groups, groups);
123
+ pass.end();
124
+ }
125
+ device.queue.submit([enc.finish()]);
126
+ await device.queue.onSubmittedWorkDone();
127
+ },
128
+ destroy() { [a, b, c, u].forEach((x) => x.destroy()); },
129
+ };
130
+ }
131
+
132
+ // Run matmul for ~ms milliseconds; returns GFLOPS (billions of multiply-adds per second).
133
+ async function timeMatmul(device, n, ms, onTick) {
134
+ const mm = makeMatmul(device, n);
135
+ try {
136
+ await mm.run(1); // warm-up + pipeline compile
137
+ let passes = 1, done = 0;
138
+ const t0 = performance.now();
139
+ while (performance.now() - t0 < ms) {
140
+ const s = performance.now();
141
+ await mm.run(passes);
142
+ done += passes;
143
+ if (performance.now() - s < 50) passes = Math.min(passes * 2, 4096);
144
+ if (onTick) onTick((performance.now() - t0) / ms);
145
+ }
146
+ const secs = (performance.now() - t0) / 1000;
147
+ return (done * n * n * n) / secs / 1e9;
148
+ } finally {
149
+ mm.destroy();
150
+ }
151
+ }
152
+
153
+ async function timeCopy(device, ms, onTick) {
154
+ const bytes = 256 * 1024 * 1024;
155
+ let src, dst;
156
+ try {
157
+ src = device.createBuffer({ size: bytes, usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.STORAGE });
158
+ dst = device.createBuffer({ size: bytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE });
159
+ } catch (e) {
160
+ return null;
161
+ }
162
+ try {
163
+ let copies = 0;
164
+ const t0 = performance.now();
165
+ while (performance.now() - t0 < ms) {
166
+ const enc = device.createCommandEncoder();
167
+ for (let i = 0; i < 4; i++) enc.copyBufferToBuffer(src, 0, dst, 0, bytes);
168
+ device.queue.submit([enc.finish()]);
169
+ await device.queue.onSubmittedWorkDone();
170
+ copies += 4;
171
+ if (onTick) onTick((performance.now() - t0) / ms);
172
+ }
173
+ const secs = (performance.now() - t0) / 1000;
174
+ return (copies * bytes) / secs / 1e9;
175
+ } finally {
176
+ src.destroy();
177
+ dst.destroy();
178
+ }
179
+ }
180
+
181
+ let cached = null;
182
+
183
+ async function detect() {
184
+ const facts = {
185
+ browser_family: browserFamily(),
186
+ os_family: osFamily(),
187
+ cpu_cores: Number.isInteger(navigator.hardwareConcurrency) ? navigator.hardwareConcurrency : null,
188
+ device_memory: typeof navigator.deviceMemory === "number" ? navigator.deviceMemory : null,
189
+ webgpu_available: false,
190
+ gpu_vendor: null,
191
+ gpu_arch: null,
192
+ quick_score: null,
193
+ capability: "unknown",
194
+ duration_ms: 0,
195
+ error: null,
196
+ };
197
+ const t0 = performance.now();
198
+ const got = await getDevice();
199
+ if (got.adapter && got.adapter.info) {
200
+ facts.gpu_vendor = token(got.adapter.info.vendor);
201
+ facts.gpu_arch = token(got.adapter.info.architecture);
202
+ }
203
+ if (!got.device) {
204
+ facts.error = got.error || "unavailable";
205
+ } else {
206
+ facts.webgpu_available = true;
207
+ try {
208
+ const gflops = await timeMatmul(got.device, 256, 1200);
209
+ facts.quick_score = Math.round((gflops / REF.mm256) * 1000);
210
+ facts.capability = capabilityClass(facts.quick_score);
211
+ } catch (e) {
212
+ facts.error = "test_failed";
213
+ }
214
+ cached = got.device;
215
+ }
216
+ facts.duration_ms = Math.round(performance.now() - t0);
217
+ facts.memory_prior = memoryPrior(facts.device_memory);
218
+ facts.capability_label = CLASS_LABEL[facts.capability];
219
+ return facts;
220
+ }
221
+
222
+ async function fullBenchmark(onProgress) {
223
+ const device = cached || (await getDevice()).device;
224
+ if (!device) throw new Error("WebGPU isn't available in this browser.");
225
+ const phases = [
226
+ ["mm256", 4000, (t) => timeMatmul(device, 256, 4000, t)],
227
+ ["mm512", 6000, (t) => timeMatmul(device, 512, 6000, t)],
228
+ ["mm1024", 7000, (t) => timeMatmul(device, 1024, 7000, t)],
229
+ ["copy", 3000, (t) => timeCopy(device, 3000, t)],
230
+ ];
231
+ const total = phases.reduce((s, p) => s + p[1], 0);
232
+ let before = 0;
233
+ const raw = {};
234
+ const t0 = performance.now();
235
+ for (const [name, ms, fn] of phases) {
236
+ raw[name] = await fn((f) => onProgress && onProgress(Math.min(99, ((before + f * ms) / total) * 100), name));
237
+ before += ms;
238
+ }
239
+ const parts = Object.entries(raw).filter(([, v]) => v).map(([k, v]) => v / REF[k]);
240
+ const score = Math.round(Math.exp(parts.reduce((s, x) => s + Math.log(x), 0) / parts.length) * 1000);
241
+ if (onProgress) onProgress(100, "done");
242
+ return {
243
+ score,
244
+ raw: Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, v && Math.round(v * 10) / 10])),
245
+ duration_ms: Math.round(performance.now() - t0),
246
+ version: BENCH_VERSION,
247
+ };
248
+ }
249
+
250
+ window.HW = { detect, fullBenchmark, BENCH_VERSION, capabilityClass };
251
+ })();
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ os.environ["EXPLORER_NO_AUTOAPP"] = "1"
6
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
tests/test_api.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+
3
+ import httpx
4
+ import pytest
5
+ from fastapi.testclient import TestClient
6
+
7
+ from app.catalogue import Catalogue
8
+ from app.main import State, create_app
9
+ from app.model_detail import DetailCache, ModelDetail
10
+ from app.sink import LocalParquetSink
11
+
12
+ from .test_parsing import QWEN3_8B_CONFIG
13
+
14
+ LISTING = [
15
+ {"id": "mlx-community/Qwen3-8B-4bit", "downloads": 90000, "likes": 50, "lastModified": "2026-08-01T00:00:00Z",
16
+ "pipeline_tag": "text-generation", "safetensors": {"total": 8.19e9}, "tags": ["4-bit", "base_model:Qwen/Qwen3-8B"]},
17
+ {"id": "mlx-community/Qwen3-8B-8bit", "downloads": 30000, "likes": 10, "lastModified": "2026-07-01T00:00:00Z",
18
+ "pipeline_tag": "text-generation", "safetensors": {"total": 8.19e9}, "tags": ["8-bit", "base_model:Qwen/Qwen3-8B"]},
19
+ {"id": "mlx-community/Qwen3-14B-4bit", "downloads": 50000, "likes": 20, "lastModified": "2026-06-01T00:00:00Z",
20
+ "pipeline_tag": "text-generation", "safetensors": {"total": 14.8e9}, "tags": ["4-bit"]},
21
+ {"id": "mlx-community/Qwen3-mystery", "pipeline_tag": "text-generation", "tags": []},
22
+ {"id": "mlx-community/gemma-3-12b-it-4bit", "downloads": 70000, "pipeline_tag": "image-text-to-text",
23
+ "safetensors": {"total": 12e9}, "tags": ["4-bit"]},
24
+ {"id": "mlx-community/whisper-large-v3", "downloads": 99999, "pipeline_tag": "automatic-speech-recognition", "tags": []},
25
+ {"broken": True},
26
+ {"id": "mlx-community/secret-private-model", "private": True, "pipeline_tag": "text-generation", "tags": ["4-bit"]},
27
+ ]
28
+
29
+
30
+ def fake_detail(mid):
31
+ if mid == "mlx-community/Qwen3-mystery":
32
+ return ModelDetail(mid, None, [], None, time.time(), error="config: HTTPStatusError")
33
+ return ModelDetail(mid, QWEN3_8B_CONFIG, [{"path": "model.safetensors", "size": 4_600_000_000}],
34
+ 4_600_000_000, time.time())
35
+
36
+
37
+ @pytest.fixture
38
+ def client(tmp_path):
39
+ cat = Catalogue(fetcher=lambda: LISTING, cache_dir=tmp_path)
40
+ cat.refresh()
41
+ st = State(catalogue=cat, details=DetailCache(fetch=fake_detail, cache_dir=tmp_path),
42
+ sink=LocalParquetSink(tmp_path / "ds", flush_seconds=999), fetch_live=False)
43
+ with TestClient(create_app(st)) as c:
44
+ c.st = st
45
+ yield c
46
+
47
+
48
+ def test_healthz_and_meta(client):
49
+ h = client.get("/healthz").json()
50
+ assert h["ok"] and h["catalogue"]["models"] == 6 and h["catalogue"]["skipped_malformed"] == 2
51
+ m = client.get("/api/meta").json()
52
+ assert "Qwen" in [f["family"] for f in m["families"] + [{"family": "Qwen"}]]
53
+ assert 32768 in m["contexts"] and 36 in m["ram_classes"]
54
+
55
+
56
+ def test_index_served_with_csp(client):
57
+ r = client.get("/")
58
+ assert r.status_code == 200 and "MLX Model Explorer" in r.text
59
+ assert "default-src 'self'" in r.headers["content-security-policy"]
60
+
61
+
62
+ def test_recommend_flow(client):
63
+ r = client.post("/api/recommend", json={"family": "Qwen", "size": "8-15B", "quant": "4-bit",
64
+ "context": 32768, "ram_gb": 36, "ram_source": "confirmed",
65
+ "priority": "balanced"})
66
+ body = r.json()
67
+ ids = [x["model"]["id"] for x in body["results"]]
68
+ assert set(ids) == {"mlx-community/Qwen3-8B-4bit", "mlx-community/Qwen3-14B-4bit"}
69
+ top = body["results"][0]
70
+ assert top["memory"]["fit"] in ("Comfortable", "Likely") and top["reasons"]
71
+ assert top["model"]["url"].startswith("https://huggingface.co/mlx-community/")
72
+
73
+
74
+ def test_llm_filter_and_unknowns(client):
75
+ body = client.post("/api/recommend", json={"family": "Qwen", "context": 8192}).json()
76
+ ids = {x["model"]["id"] for x in body["results"]}
77
+ assert "mlx-community/Qwen3-mystery" in ids and "mlx-community/whisper-large-v3" not in ids
78
+ mystery = next(x for x in body["results"] if x["model"]["id"] == "mlx-community/Qwen3-mystery")
79
+ assert mystery["model"]["quant_bucket"] == "unknown" and mystery["memory"]["total_gb"] is None
80
+ assert any("isn't available" in n for n in body["notices"])
81
+ allm = client.post("/api/recommend", json={"llm_only": False, "context": 8192}).json()
82
+ assert allm["total"] == 6
83
+
84
+
85
+ def test_sorts(client):
86
+ pop = client.post("/api/recommend", json={"family": "Qwen", "sort": "popular"}).json()
87
+ dls = [x["model"]["downloads"] for x in pop["results"]]
88
+ assert dls == sorted(dls, reverse=True)
89
+ rec = client.post("/api/recommend", json={"family": "Qwen", "sort": "recent"}).json()
90
+ assert rec["results"][0]["model"]["id"] == "mlx-community/Qwen3-8B-4bit"
91
+
92
+
93
+ def test_bad_recommend_inputs(client):
94
+ assert client.post("/api/recommend", json={"context": 12345}).status_code == 422
95
+ assert client.post("/api/recommend", json={"priority": "evil"}).status_code == 422
96
+ assert client.post("/api/recommend", json={"ram_gb": -3}).status_code == 422
97
+ assert client.post("/api/recommend", json={"family": "NoSuchFamily"}).json()["total"] == 0
98
+
99
+
100
+ def test_model_detail(client):
101
+ d = client.get("/api/model/mlx-community/Qwen3-8B-4bit", params={"ram_gb": 16, "context": 32768}).json()
102
+ assert len(d["memory_by_context"]) == 7
103
+ assert [s["model"]["id"] for s in d["siblings"]] == ["mlx-community/Qwen3-8B-8bit"]
104
+ assert d["memory"]["weights_source"] == "files"
105
+ assert client.get("/api/model/mlx-community/nope").status_code == 404
106
+ junk = client.get("/api/model/mlx-community/Qwen3-8B-4bit", params={"context": 3, "priority": "x"})
107
+ assert junk.status_code == 200
108
+
109
+
110
+ def test_events_ingest_to_parquet(client, tmp_path):
111
+ sid = "abcdef0123456789"
112
+ batch = {"events": [
113
+ {"event_type": "search", "session_id": sid, "model_family": "Qwen", "quantization": "4-bit",
114
+ "target_context": 32768, "priority": "balanced", "hardware_source": "confirmed", "hardware_memory_class": 36},
115
+ {"event_type": "model_select", "session_id": sid, "selected_model": "mlx-community/Qwen3-8B-4bit",
116
+ "selected_model_rank": 0, "target_context": 32768, "hardware_memory_class": 36},
117
+ {"event_type": "feedback", "session_id": sid, "selected_model": "mlx-community/Qwen3-8B-4bit",
118
+ "tried": "yes", "quality_rating": "good", "notes": "worked fine, email me x@y.com"},
119
+ ]}
120
+ r = client.post("/api/events", json=batch)
121
+ assert r.status_code == 202 and r.json()["accepted"] == 3
122
+ assert client.st.sink.flush()
123
+ rows = client.st.sink.read_existing()
124
+ assert [x["event_type"] for x in rows] == ["search", "model_select", "feedback"]
125
+ assert rows[1]["hf_downloads_at_selection"] == 90000
126
+ assert "x@y.com" not in rows[2]["notes"]
127
+ stats = client.get("/api/stats").json()
128
+ assert stats["totals"]["events"] == 3 and stats["totals"]["sessions"] == 1
129
+ assert stats["families"] == [{"key": "other", "count": 1, "share": 1.0}] # k-anonymity folds tiny buckets
130
+
131
+
132
+ def test_events_reject_malformed(client):
133
+ assert client.post("/api/events", content=b"not json", headers={"content-type": "application/json"}).status_code == 400
134
+ assert client.post("/api/events", json={"events": [{"event_type": "hack"}]}).status_code == 422
135
+ assert client.post("/api/events", json={"events": [{"event_type": "search", "extra": 1}]}).status_code == 422
136
+ big = b'{"events": [' + b'{"event_type":"search"},' * 5000 + b'{"event_type":"search"}]}'
137
+ assert client.post("/api/events", content=big, headers={"content-type": "application/json"}).status_code == 413
138
+ assert client.st.sink.pending == 0
139
+
140
+
141
+ def test_rate_limit(client):
142
+ client.st.limiter.burst = 3
143
+ client.st.limiter._buckets.clear()
144
+ codes = [client.post("/api/events", json={"events": [{"event_type": "search"}]}).status_code for _ in range(8)]
145
+ assert 429 in codes
146
+
147
+
148
+ def test_hub_failure_falls_back_to_snapshot(tmp_path):
149
+ def boom():
150
+ raise httpx.ConnectError("hub unreachable")
151
+
152
+ cat = Catalogue(fetcher=boom, cache_dir=tmp_path)
153
+ cat.refresh()
154
+ assert cat.source == "snapshot" and len(cat.records) > 1000 and cat.last_error
155
+ st = State(catalogue=cat, details=DetailCache(fetch=fake_detail, cache_dir=tmp_path),
156
+ sink=LocalParquetSink(tmp_path / "ds", flush_seconds=999), fetch_live=False)
157
+ with TestClient(create_app(st)) as c:
158
+ body = c.post("/api/recommend", json={"family": "Qwen", "quant": "4-bit"}).json()
159
+ assert body["total"] > 0 and any("cached copy" in n for n in body["notices"])
160
+ assert c.get("/healthz").json()["catalogue"]["degraded"]
161
+
162
+
163
+ def test_collection_disabled(client):
164
+ client.st.collection_enabled = False
165
+ r = client.post("/api/events", json={"events": [{"event_type": "search"}]})
166
+ assert r.json() == {"accepted": 0} and client.st.sink.pending == 0
167
+
168
+
169
+ def test_stats_dedupe_and_suppression():
170
+ from app.stats import compute
171
+ rows = []
172
+ for i in range(6):
173
+ sid = f"{i:016x}"
174
+ for _ in range(3): # the same query re-rendered three times counts once
175
+ rows.append({"event_type": "filter", "session_id": sid, "model_family": "Qwen", "quantization": "4-bit",
176
+ "target_context": 8192, "hardware_memory_class": 36, "suspicious_flags": []})
177
+ rows.append({"event_type": "search", "session_id": "ffffffffffffffff", "model_family": "Rare",
178
+ "quantization": "3-bit", "suspicious_flags": []})
179
+ rows.append({"event_type": "search", "model_family": "Flagged", "suspicious_flags": ["unknown_model"]})
180
+ s = compute(rows)
181
+ assert s["totals"]["distinct_queries"] == 7
182
+ assert s["families"] == [{"key": "Qwen", "count": 6, "share": 0.8571}, {"key": "other", "count": 1, "share": 0.1429}]
183
+ assert s["ram_classes"][0] == {"key": 36, "count": 6, "share": 1.0}
184
+ assert all(f["key"] != "Flagged" for f in s["families"])
tests/test_events.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from pydantic import ValidationError
3
+
4
+ from app.catalogue import Catalogue, build_record
5
+ from app.events import COLUMNS, ClientEvent, EventBatch, clean_notes, to_row
6
+
7
+ SID = "0123456789abcdef"
8
+
9
+
10
+ class FakeCatalogue:
11
+ def __init__(self, recs):
12
+ self.by_id = {r.id: r for r in recs}
13
+
14
+
15
+ CAT = FakeCatalogue([
16
+ build_record({"id": "mlx-community/Qwen3-8B-4bit", "downloads": 42, "likes": 3,
17
+ "safetensors": {"total": 8.19e9}, "tags": ["4-bit"], "pipeline_tag": "text-generation"}),
18
+ build_record({"id": "mlx-community/Llama-3.3-70B-Instruct-4bit", "safetensors": {"total": 70e9},
19
+ "tags": ["4-bit"], "pipeline_tag": "text-generation"}),
20
+ ])
21
+
22
+
23
+ def ev(**kw):
24
+ return ClientEvent(**{"event_type": "search", "session_id": SID, **kw})
25
+
26
+
27
+ def test_valid_search_row_has_all_columns_and_no_pii():
28
+ row = to_row(ev(model_family="Qwen", parameter_bucket="8-15B", quantization="4-bit",
29
+ target_context=32768, priority="balanced", hardware_source="confirmed",
30
+ hardware_memory_class=36), CAT)
31
+ assert set(row) == set(COLUMNS)
32
+ assert row["hardware_confirmed"] is True and row["suspicious_flags"] == []
33
+ assert row["timestamp"].endswith("Z") and "." not in row["timestamp"]
34
+ for forbidden in ("ip", "user_agent", "email", "name", "cookie"):
35
+ assert forbidden not in row
36
+
37
+
38
+ def test_server_fills_catalogue_facts():
39
+ row = to_row(ev(event_type="model_select", selected_model="mlx-community/Qwen3-8B-4bit",
40
+ selected_model_rank=0), CAT)
41
+ assert row["hf_downloads_at_selection"] == 42 and row["quant_bits"] == 4
42
+ assert row["model_family"] == "Qwen" and row["parameter_bucket"] == "8-15B"
43
+
44
+
45
+ @pytest.mark.parametrize("bad", [
46
+ {"event_type": "delete_everything"},
47
+ {"event_type": "search", "session_id": "NOT-HEX"},
48
+ {"event_type": "search", "target_context": 12345},
49
+ {"event_type": "search", "hardware_memory_class": 17},
50
+ {"event_type": "search", "quantization": "7-bit"},
51
+ {"event_type": "feedback", "reported_tokens_per_second": -1},
52
+ {"event_type": "feedback", "reported_tokens_per_second": 99999},
53
+ {"event_type": "search", "selected_model": "../../etc/passwd"},
54
+ {"event_type": "search", "selected_model": "<script>/x"},
55
+ {"event_type": "mlx_benchmark_submission", "chip": "Intel i9"},
56
+ {"event_type": "search", "ip": "1.2.3.4"},
57
+ {"event_type": "search", "model_family": "<img src=x>"},
58
+ {"event_type": "search", "benchmark_version": "1.0; rm -rf"},
59
+ {"event_type": "search", "compare_models": ["a/b"] * 6},
60
+ ])
61
+ def test_rejects_malformed(bad):
62
+ with pytest.raises(ValidationError):
63
+ ClientEvent(**bad)
64
+
65
+
66
+ def test_batch_limits():
67
+ with pytest.raises(ValidationError):
68
+ EventBatch(events=[])
69
+ with pytest.raises(ValidationError):
70
+ EventBatch(events=[{"event_type": "search"}] * 51)
71
+
72
+
73
+ def test_notes_are_scrubbed_and_capped():
74
+ n = clean_notes("mail me a@b.com\x00 see https://evil.example/x or call +1 (555) 123-4567 " + "z" * 500)
75
+ assert "a@b.com" not in n and "https" not in n and "555" not in n and "\x00" not in n
76
+ assert len(n) <= 280
77
+ assert clean_notes(" ") is None
78
+ e = ev(event_type="feedback", notes="<script>alert(1)</script>")
79
+ assert e.notes == "<script>alert(1)</script>" # stored as inert text; UI renders textContent only
80
+
81
+
82
+ def test_flags_unknown_model_and_implausible_tps():
83
+ row = to_row(ev(event_type="mlx_benchmark_submission", selected_model="mlx-community/Nope-1B",
84
+ generation_tps=10, benchmark_version="1", benchmark_type="mlx_lm"), CAT)
85
+ assert "unknown_model" in row["suspicious_flags"]
86
+ row = to_row(ev(event_type="mlx_benchmark_submission", selected_model="mlx-community/Llama-3.3-70B-Instruct-4bit",
87
+ generation_tps=1500, benchmark_version="1", benchmark_type="mlx_lm"), CAT)
88
+ assert "implausible_tps" in row["suspicious_flags"]
89
+ row = to_row(ev(event_type="mlx_benchmark_submission", selected_model="mlx-community/Qwen3-8B-4bit",
90
+ generation_tps=45, benchmark_version="1", benchmark_type="mlx_lm", peak_memory_gb=5.1,
91
+ reported_ram_gb=36), CAT)
92
+ assert row["suspicious_flags"] == []
93
+
94
+
95
+ def test_flags_other_inconsistencies():
96
+ row = to_row(ev(event_type="mlx_benchmark_submission", peak_memory_gb=40, reported_ram_gb=16), CAT)
97
+ assert {"peak_memory_exceeds_ram", "incomplete_benchmark"} <= set(row["suspicious_flags"])
98
+ row = to_row(ev(event_type="feedback", tried="yes", quality_rating="good", failure_reason="too_slow"), CAT)
99
+ assert "conflicting_feedback" in row["suspicious_flags"]
100
+ row = to_row(ev(event_type="browser_benchmark", benchmark_type="mlx_lm"), CAT)
101
+ assert "benchmark_type_mismatch" in row["suspicious_flags"]
tests/test_memory.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.memory import GB, estimate, fit_class, kv_cache_bytes, usable_gpu_bytes, weights_bytes
2
+ from app.parsing import detect_arch
3
+
4
+ from .test_parsing import QWEN3_8B_CONFIG
5
+
6
+
7
+ def test_kv_cache_qwen3_8b_32k_by_hand():
8
+ # 2 (K,V) * 36 layers * 8 kv heads * 128 head_dim * 32768 tokens * 2 bytes
9
+ expected = 2 * 36 * 8 * 128 * 32768 * 2
10
+ kv, upper = kv_cache_bytes(detect_arch(QWEN3_8B_CONFIG), 32768)
11
+ assert kv == expected and not upper
12
+ assert round(kv / GB, 2) == 4.5
13
+
14
+
15
+ def test_sliding_window_caps_kv():
16
+ cfg = {"num_hidden_layers": 4, "num_attention_heads": 8, "num_key_value_heads": 8, "head_dim": 64,
17
+ "sliding_window": 128, "layer_types": ["sliding_attention", "full_attention"] * 2}
18
+ arch = detect_arch(cfg)
19
+ per = 2 * 8 * 64 * 2
20
+ kv, _ = kv_cache_bytes(arch, 32768)
21
+ assert kv == 2 * 32768 * per + 2 * 128 * per
22
+
23
+
24
+ def test_missing_config_is_flagged_rough():
25
+ est = estimate(params=8e9, bits=4, mode="affine", file_bytes=None, arch=detect_arch(None),
26
+ context=32768, ram_gb=16)
27
+ assert est.kv_rough and est.kv_gb and est.weights_source == "params"
28
+
29
+
30
+ def test_weights_prefer_files():
31
+ assert weights_bytes(4_619_257_828, 8e9, 4) == (4_619_257_828, "files")
32
+ b, src = weights_bytes(None, 8e9, 4)
33
+ assert src == "params" and abs(b / 1e9 - 4.5) < 0.01
34
+ assert weights_bytes(None, None, 4) == (None, "unknown")
35
+
36
+
37
+ def test_usable_memory_and_fit():
38
+ assert usable_gpu_bytes(16) < usable_gpu_bytes(24) < usable_gpu_bytes(64)
39
+ assert fit_class(int(5 * GB), 36) == "Comfortable"
40
+ assert fit_class(int(12 * GB), 16) == "Unlikely"
41
+ assert fit_class(None, 16) is None and fit_class(int(GB), None) is None
42
+
43
+
44
+ def test_context_changes_estimate_and_model_limit():
45
+ arch = detect_arch(QWEN3_8B_CONFIG)
46
+ small = estimate(params=8e9, bits=4, mode=None, file_bytes=None, arch=arch, context=4096, ram_gb=16)
47
+ big = estimate(params=8e9, bits=4, mode=None, file_bytes=None, arch=arch, context=131072, ram_gb=16)
48
+ assert big.total_gb > small.total_gb
49
+ assert small.fit in ("Comfortable", "Likely") and big.fit == "Unlikely"
50
+ assert big.exceeds_model_context and not small.exceeds_model_context
tests/test_parsing.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gzip
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from app.catalogue import build_record
8
+ from app.parsing import (
9
+ detect_arch, detect_family, detect_params, detect_quant, param_bucket, quant_bucket,
10
+ )
11
+
12
+ QWEN3_8B_CONFIG = {
13
+ "architectures": ["Qwen3ForCausalLM"], "model_type": "qwen3", "head_dim": 128, "hidden_size": 4096,
14
+ "max_position_embeddings": 40960, "num_attention_heads": 32, "num_hidden_layers": 36,
15
+ "num_key_value_heads": 8, "quantization": {"group_size": 64, "bits": 4}, "sliding_window": None,
16
+ "use_sliding_window": False,
17
+ }
18
+
19
+
20
+ @pytest.mark.parametrize("mid,tags,family", [
21
+ ("mlx-community/Qwen3.8-27B-4bit", ["base_model:Qwen/Qwen3.8-27B"], "Qwen"),
22
+ ("mlx-community/Josiefied-Qwen3-8B-abliterated-v1-4bit", [], "Qwen"),
23
+ ("mlx-community/Meta-Llama-3.1-8B-Instruct-4bit", [], "Llama"),
24
+ ("mlx-community/gemma-4-31B-it-qat-4bit", [], "Gemma"),
25
+ ("mlx-community/Devstral-Small-2507-4bit", [], "Mistral"),
26
+ ("mlx-community/DeepSeek-V4-Flash-2bit", [], "DeepSeek"),
27
+ ("mlx-community/Kimi-K2-Instruct-4bit", [], "Kimi"),
28
+ ("mlx-community/NVIDIA-Nemotron-3-Nano-4bit", [], "Nemotron"),
29
+ ("mlx-community/Zorblax-7B-4bit", [], "Zorblax"), # never seen before -> still a family
30
+ ("mlx-community/7B", [], "Other"),
31
+ ("mlx-community/functiongemma-270m-it-4bit", [], "Gemma"),
32
+ ("mlx-community/OLMoE-1B-7B-0924-Instruct-4bit", [], "OLMo"),
33
+ ("mlx-community/Instruct-Zorblax-7B", [], "Zorblax"),
34
+ ("mlx-community/Starling-LM-7B-beta", [], "Starling"),
35
+ ])
36
+ def test_family(mid, tags, family):
37
+ assert detect_family(mid, tags) == family
38
+
39
+
40
+ def test_family_ignores_junk_tags():
41
+ assert detect_family("mlx-community/Qwen3-8B-4bit", [None, 5, "base_model:"]) == "Qwen"
42
+
43
+
44
+ def test_params_prefers_safetensors_then_name():
45
+ p = detect_params("mlx-community/Qwen3-8B-4bit", {"total": 8_190_735_360})
46
+ assert p.source == "safetensors" and param_bucket(p.total) == "8-15B"
47
+ p = detect_params("mlx-community/Qwen3-8B-4bit", None)
48
+ assert p.source == "name" and p.total == 8e9 and param_bucket(p.total) == "8-15B"
49
+
50
+
51
+ def test_params_moe():
52
+ p = detect_params("mlx-community/Qwen3-30B-A3B-4bit", {"total": 30.5e9})
53
+ assert p.moe and p.active == 3e9 and param_bucket(p.total) == "15-35B"
54
+ p = detect_params("mlx-community/Mixtral-8x7B-Instruct-v0.1", None)
55
+ assert p.moe and p.total == 56e9
56
+ p = detect_params("mlx-community/whatever-4bit", None, {"num_local_experts": 8})
57
+ assert p.moe
58
+
59
+
60
+ @pytest.mark.parametrize("st", [None, {}, {"total": "big"}, {"total": -1}, "junk"])
61
+ def test_params_unknown(st):
62
+ p = detect_params("mlx-community/mystery-model-4bit", st)
63
+ assert p.total is None and p.source == "unknown" and param_bucket(p.total) is None
64
+
65
+
66
+ def test_quant_tags():
67
+ q = detect_quant("mlx-community/Qwen3-8B", ["4-bit"])
68
+ assert (q.bits, q.source, q.mixed, quant_bucket(q)) == (4, "tags", False, "4-bit")
69
+
70
+
71
+ def test_quant_mixed_from_tags_and_config():
72
+ q = detect_quant("mlx-community/Qwen3.5-9B-OptiQ-4bit", ["4bit", "8bit", "4-bit"])
73
+ assert q.bits == 4 and q.mixed and q.label == "4-bit mixed" and quant_bucket(q) == "4-bit"
74
+ cfg = {"model_type": "x", "quantization": {"bits": 4, "group_size": 64,
75
+ "model.layers.0.mlp": {"bits": 8, "group_size": 64}}}
76
+ q = detect_quant("mlx-community/foo", [], cfg)
77
+ assert q.bits == 4 and q.mixed and q.source == "config"
78
+
79
+
80
+ def test_quant_config_beats_name():
81
+ q = detect_quant("mlx-community/foo-8bit", ["8-bit"], QWEN3_8B_CONFIG)
82
+ assert q.bits == 4 and q.source == "config"
83
+
84
+
85
+ @pytest.mark.parametrize("name,bits,mode", [
86
+ ("gpt-oss-20b-MXFP4-Q8", 4, "mxfp4"),
87
+ ("Qwen3-30B-A3B-nvfp4", 4, "nvfp4"),
88
+ ("Qwen3-14B-4bit-DWQ", 4, "dwq"),
89
+ ("Llama-3.2-3B-Instruct-bf16", 16, "float"),
90
+ ("Mistral-7B-Instruct-v0.2-8-bit-mlx", 8, "affine"),
91
+ ("llama2-13b-qnt4bit", 4, "affine"),
92
+ ("Yi-9B-q", None, None),
93
+ ])
94
+ def test_quant_names(name, bits, mode):
95
+ q = detect_quant(f"mlx-community/{name}", [])
96
+ assert q.bits == bits and q.mode == mode
97
+
98
+
99
+ def test_quant_unquantized_config():
100
+ q = detect_quant("mlx-community/phi-2", [], {"model_type": "phi", "torch_dtype": "float16"})
101
+ assert q.bits == 16 and q.label == "16-bit"
102
+
103
+
104
+ @pytest.mark.parametrize("cfg", [None, "junk", {}, {"quantization": "4"}, {"quantization": {"bits": "x"}}])
105
+ def test_quant_junk_config(cfg):
106
+ q = detect_quant("mlx-community/mystery", [], cfg)
107
+ assert q.bits is None and q.label == "unknown"
108
+
109
+
110
+ def test_arch_standard():
111
+ a = detect_arch(QWEN3_8B_CONFIG)
112
+ assert a.known and (a.layers, a.full_attention_layers, a.kv_heads, a.head_dim) == (36, 36, 8, 128)
113
+ assert a.sliding_layers == 0 and a.max_context == 40960
114
+
115
+
116
+ def test_arch_hybrid_linear_attention():
117
+ cfg = {"text_config": {"num_hidden_layers": 8, "num_attention_heads": 16, "num_key_value_heads": 4,
118
+ "head_dim": 256, "layer_types": ["linear_attention"] * 3 + ["full_attention"]
119
+ + ["linear_attention"] * 3 + ["full_attention"]}}
120
+ a = detect_arch(cfg)
121
+ assert a.full_attention_layers == 2 and a.sliding_layers == 0
122
+
123
+
124
+ def test_arch_sliding():
125
+ cfg = {"num_hidden_layers": 4, "num_attention_heads": 8, "num_key_value_heads": 8, "head_dim": 64,
126
+ "sliding_window": 128, "layer_types": ["sliding_attention", "full_attention"] * 2}
127
+ a = detect_arch(cfg)
128
+ assert (a.full_attention_layers, a.sliding_layers, a.sliding_window) == (2, 2, 128)
129
+
130
+
131
+ @pytest.mark.parametrize("cfg", [None, {}, {"num_hidden_layers": "x"}, {"num_hidden_layers": True}])
132
+ def test_arch_unknown(cfg):
133
+ assert not detect_arch(cfg).known
134
+
135
+
136
+ @pytest.mark.parametrize("raw", [{}, {"id": 5}, {"id": "noslash"}, {"id": "a/b", "tags": "notalist"},
137
+ {"id": "a/b", "downloads": "many", "safetensors": [1]}])
138
+ def test_build_record_never_raises(raw):
139
+ rec = build_record(raw)
140
+ assert rec is None or rec.id == "a/b"
141
+
142
+
143
+ def test_snapshot_parses_fully():
144
+ path = Path(__file__).resolve().parent.parent / "data" / "catalogue_snapshot.json.gz"
145
+ raw = json.load(gzip.open(path, "rt"))
146
+ recs = [r for r in map(build_record, raw) if r]
147
+ assert len(recs) == len(raw) > 1000
148
+ llm = [r for r in recs if r.is_llm]
149
+ known_quant = sum(r.quant_bucket != "unknown" for r in llm) / len(llm)
150
+ known_size = sum(r.size_bucket is not None for r in llm) / len(llm)
151
+ assert known_quant > 0.9 and known_size > 0.95
152
+
153
+
154
+ def test_partial_repo_detected():
155
+ p = detect_params("mlx-community/Qwen3.8-27B-MTP-4bit", {"total": 424_699_392})
156
+ assert p.partial and p.total == 424_699_392 and p.name_total == 27e9
157
+ assert not detect_params("mlx-community/Qwen3-8B-4bit", {"total": 8.19e9}).partial
158
+ assert not detect_params("mlx-community/Qwen3-30B-A3B-4bit", {"total": 30.5e9}).partial
159
+
160
+
161
+ def test_packed_param_counts_are_unpacked():
162
+ st = {"parameters": {"F16": 244_584_448, "U32": 977_272_832}, "total": 1_221_857_280}
163
+ p = detect_params("mlx-community/EXAONE-3.5-7.8B-Instruct-4bit", st, bits=4)
164
+ assert abs(p.total / 1e9 - 7.82) < 0.05 and not p.partial and param_bucket(p.total) == "3-8B"
165
+ st6 = {"parameters": {"F16": 244_584_448, "U32": 1_465_909_248}, "total": 1_710_493_696}
166
+ assert abs(detect_params("mlx-community/x-6bit", st6, bits=6).total / 1e9 - 7.82) < 0.05
167
+ big = {"parameters": {"F16": 3_468_959_744, "U32": 8_879_865_856}, "total": 12_348_825_600}
168
+ assert 71e9 < detect_params("mlx-community/Liberated-Qwen1.5-72B-4bit", big, bits=4).total < 74e9
169
+ new_style = {"parameters": {"U32": 8_190_427_136, "BF16": 308_224}, "total": 8_190_735_360}
170
+ assert detect_params("mlx-community/Qwen3-8B-4bit", new_style, bits=4).total == 8_190_735_360
171
+ # packed counts but no bit width: keep the Hub total, the partial check flags the mismatch
172
+ p = detect_params("mlx-community/EXAONE-3.5-7.8B-Instruct", st, bits=None)
173
+ assert p.total == st["total"] and p.partial
174
+ # vision-language models have lots of unquantized float weights but are unpacked
175
+ vl = {"parameters": {"U32": 7_615_283_200, "F16": 676_883_456}, "total": 8_292_166_656}
176
+ assert detect_params("mlx-community/Qwen2.5-VL-7B-Instruct-4bit", vl, bits=4).total == vl["total"]
177
+ vl2 = {"parameters": {"U32": 4_022_272_000, "BF16": 415_543_808}, "total": 4_437_815_808}
178
+ assert detect_params("mlx-community/some-vlm-4bit", vl2, bits=4).total == vl2["total"]
179
+ assert detect_params("mlx-community/some-vlm-8bit", vl2, bits=8).total == vl2["total"]
180
+ assert abs(detect_params("mlx-community/unnamed-4bit", st, bits=4).total / 1e9 - 7.82) < 0.05
181
+
182
+
183
+ def test_unknown_quantization_format_does_not_mean_unquantized():
184
+ cfg = {"model_type": "gemma4", "quantization_config": {"quant_method": "gemma", "module_quant_configs": {"x": {"num_bits": 2}}}}
185
+ q = detect_quant("mlx-community/gemma-4-E4B-it-qat-mobile", ["8-bit"], cfg)
186
+ assert q.bits == 8 and q.source == "tags"
tests/test_recommend.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from dataclasses import replace
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ from app.catalogue import build_record
7
+ from app.model_detail import ModelDetail
8
+ from app.recommend import CommunitySignal, HeuristicEngine, Query, sort_results
9
+
10
+ from .test_parsing import QWEN3_8B_CONFIG
11
+
12
+ NOW = datetime(2026, 9, 1, tzinfo=timezone.utc)
13
+ ENGINE = HeuristicEngine(now=NOW)
14
+
15
+
16
+ def rec(name, total, bits, downloads=1000, modified="2026-08-01T00:00:00Z", tags=None):
17
+ return build_record({
18
+ "id": f"mlx-community/{name}", "downloads": downloads, "likes": 1, "lastModified": modified,
19
+ "pipeline_tag": "text-generation", "safetensors": {"total": total},
20
+ "tags": (tags or []) + [f"{bits}-bit"],
21
+ })
22
+
23
+
24
+ def family():
25
+ return [
26
+ rec("Fam-8B-2bit", 8e9, 2), rec("Fam-8B-3bit", 8e9, 3), rec("Fam-8B-4bit", 8e9, 4),
27
+ rec("Fam-8B-6bit", 8e9, 6), rec("Fam-8B-8bit", 8e9, 8),
28
+ ]
29
+
30
+
31
+ def ranked(models, q, details=None, community=None):
32
+ return [s.model.name for s in sort_results(ENGINE.score(models, q, details or {}, community or {}), "recommended")]
33
+
34
+
35
+ def test_priority_changes_order():
36
+ ms = family()
37
+ quality = ranked(ms, Query(ram_gb=64, priority="quality"))
38
+ memory = ranked(ms, Query(ram_gb=64, priority="memory"))
39
+ assert quality[0] == "Fam-8B-8bit"
40
+ assert memory[0] == "Fam-8B-2bit"
41
+ assert ranked(ms, Query(ram_gb=64, priority="balanced"))[0] == "Fam-8B-4bit"
42
+
43
+
44
+ def test_ram_changes_order():
45
+ ms = family()
46
+ big = ranked(ms, Query(ram_gb=64, priority="quality"))
47
+ small = ranked(ms, Query(ram_gb=8, priority="quality"))
48
+ assert big[0] == "Fam-8B-8bit"
49
+ assert small[0] != "Fam-8B-8bit" # 8-bit 8B (~8.5 GB) can't load on an 8 GB Mac
50
+ scored = {s.model.name: s for s in ENGINE.score(ms, Query(ram_gb=8, priority="quality"))}
51
+ assert scored["Fam-8B-8bit"].memory.fit == "Unlikely"
52
+
53
+
54
+ def test_context_changes_score():
55
+ m = rec("Fam-8B-4bit", 8e9, 4)
56
+ d = {m.id: ModelDetail(m.id, QWEN3_8B_CONFIG, [], None, 0)}
57
+ short = ENGINE.score([m], Query(ram_gb=16, context=4096), d)[0]
58
+ long = ENGINE.score([m], Query(ram_gb=16, context=131072), d)[0]
59
+ assert long.memory.total_gb > short.memory.total_gb
60
+ assert long.score < short.score
61
+ assert any("shorter maximum context" in r for r in long.reasons)
62
+
63
+
64
+ def test_quant_changes_score_for_same_model():
65
+ a, b = rec("Fam-8B-4bit", 8e9, 4), rec("Fam-8B-3bit", 8e9, 3)
66
+ q = Query(ram_gb=64, priority="balanced")
67
+ s = {x.model.name: x.score for x in ENGINE.score([a, b], q)}
68
+ assert s["Fam-8B-4bit"] > s["Fam-8B-3bit"]
69
+
70
+
71
+ def test_reasons_present_and_no_perf_claims():
72
+ for s in ENGINE.score(family(), Query(ram_gb=16, priority="speed")):
73
+ assert s.reasons
74
+ text = " ".join(s.reasons).lower()
75
+ assert "tok/s" not in text # no invented throughput without community data
76
+ assert "depends" in text
77
+
78
+
79
+ def test_unknown_ram_and_unknown_memory_degrade_gracefully():
80
+ m = build_record({"id": "mlx-community/mystery", "tags": []})
81
+ s = ENGINE.score([m], Query())[0]
82
+ assert s.memory.total_gb is None and s.memory.fit is None
83
+ assert any("isn't available" in r for r in s.reasons)
84
+
85
+
86
+ def test_neutral_to_names_and_brands():
87
+ base = rec("Qwen3-8B-4bit", 8e9, 4, downloads=5000)
88
+ renamed = replace(base, id="mlx-community/Qwen3-8B-OptiQ-4bit", name="Qwen3-8B-OptiQ-4bit")
89
+ other_org = replace(base, id="someone/Qwen3-8B-4bit", name="Qwen3-8B-4bit")
90
+ q = Query(ram_gb=24, priority="balanced", context=32768)
91
+ scores = [ENGINE.score([m], q, {}, {})[0].score for m in (base, renamed, other_org)]
92
+ assert scores[0] == scores[1] == scores[2]
93
+
94
+
95
+ def test_engine_source_mentions_no_product_names():
96
+ src = (Path(__file__).resolve().parent.parent / "app" / "recommend.py").read_text().lower()
97
+ code = re.sub(r'"""[\s\S]*?"""', "", src) # the module docstring may explain the rule
98
+ for word in ("optiq", "unsloth", "lmstudio", "bartowski", "dwq"):
99
+ assert word not in code
100
+
101
+
102
+ def test_popularity_and_community_break_ties():
103
+ a = rec("A-8B-4bit", 8e9, 4, downloads=100)
104
+ b = rec("B-8B-4bit", 8e9, 4, downloads=100_000)
105
+ assert ranked([a, b], Query(ram_gb=32))[0] == "B-8B-4bit"
106
+ comm = {a.id: CommunitySignal(feedback_count=30, positive_share=1.0, benchmark_count=20,
107
+ median_generation_tps=40)}
108
+ s = {x.model.name: x for x in ENGINE.score([a, b], Query(ram_gb=32), {}, comm)}
109
+ assert s["A-8B-4bit"].components["community"] > 0
110
+ assert any("MLX benchmark" in r for r in s["A-8B-4bit"].reasons)
111
+
112
+
113
+ def test_sorts():
114
+ old = rec("Old-8B-4bit", 8e9, 4, downloads=10**6, modified="2024-01-01T00:00:00Z")
115
+ new = rec("New-8B-4bit", 8e9, 4, downloads=10, modified="2026-08-30T00:00:00Z")
116
+ scored = ENGINE.score([old, new], Query(ram_gb=32))
117
+ assert sort_results(scored, "popular")[0].model.name == "Old-8B-4bit"
118
+ assert sort_results(scored, "recent")[0].model.name == "New-8B-4bit"
119
+ assert [s.model.name for s in sort_results(scored, "all")] == ["New-8B-4bit", "Old-8B-4bit"]
120
+
121
+
122
+ def test_config_quant_overrides_misleading_name():
123
+ m = rec("Fam-9B-4bit", 9e9, 4)
124
+ cfg = {"model_type": "x", "num_hidden_layers": 4, "num_attention_heads": 8, "head_dim": 64}
125
+ d = {m.id: ModelDetail(m.id, cfg, [], 18_000_000_000, 0)}
126
+ s = ENGINE.score([m], Query(ram_gb=36), d)[0]
127
+ assert s.quant["bits"] == 16 and s.to_dict()["quant"]["label"] == "16-bit"
128
+ assert any("config.json says 16-bit" in r for r in s.reasons)
129
+
130
+
131
+ def test_likes_count_toward_popularity():
132
+ a = rec("A-8B-4bit", 8e9, 4, downloads=20_000)
133
+ b = replace(rec("B-8B-4bit", 8e9, 4, downloads=20_000), likes=150)
134
+ assert ranked([a, b], Query(ram_gb=32))[0] == "B-8B-4bit"
135
+
136
+
137
+ def test_capacity_prefers_larger_models_that_fit():
138
+ small = rec("Fam-1B-4bit", 1e9, 4, downloads=5000)
139
+ mid = rec("Fam-8B-4bit", 8e9, 4, downloads=5000)
140
+ huge = rec("Fam-70B-4bit", 70e9, 4, downloads=5000)
141
+ order = ranked([small, mid, huge], Query(ram_gb=36, priority="balanced"))
142
+ assert order[0] == "Fam-8B-4bit" and order[-1] == "Fam-70B-4bit" # 70B doesn't fit 36 GB
143
+ assert ranked([small, mid, huge], Query(ram_gb=128, priority="quality"))[0] == "Fam-70B-4bit"
144
+
145
+
146
+ def test_norms_make_scores_page_independent():
147
+ ms = [rec(f"M{i}-8B-4bit", 8e9, 4, downloads=10 ** i) for i in range(1, 6)]
148
+ q = Query(ram_gb=32)
149
+ norms = ENGINE.norms(ms, q)
150
+ full = {s.model.id: s.score for s in ENGINE.score(ms, q, {}, {}, norms)}
151
+ page = {s.model.id: s.score for s in ENGINE.score(ms[:2], q, {}, {}, norms)}
152
+ assert all(abs(full[k] - v) < 1e-9 for k, v in page.items())
tests/test_sink.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pyarrow.parquet as pq
2
+ import pytest
3
+
4
+ from app.events import COLUMNS, ClientEvent, to_row
5
+ from app.sink import HubParquetSink, LocalParquetSink, rows_to_parquet_bytes
6
+
7
+
8
+ def rows(n):
9
+ return [to_row(ClientEvent(event_type="search", model_family="Qwen", quantization="4-bit")) for _ in range(n)]
10
+
11
+
12
+ def test_batch_becomes_one_shard(tmp_path):
13
+ s = LocalParquetSink(tmp_path, flush_seconds=999)
14
+ s.add(rows(7))
15
+ s.add(rows(5))
16
+ assert s.flush()
17
+ shards = list((tmp_path / "data/events").rglob("*.parquet"))
18
+ assert len(shards) == 1
19
+ t = pq.read_table(shards[0])
20
+ assert t.num_rows == 12 and t.column_names == list(COLUMNS)
21
+ assert s.flush() and len(list((tmp_path / "data/events").rglob("*.parquet"))) == 1 # empty flush = no shard
22
+ s.add(rows(1))
23
+ s.flush()
24
+ assert len(list((tmp_path / "data/events").rglob("*.parquet"))) == 2 # appends, never rewrites
25
+ assert len(s.read_existing()) == 13
26
+
27
+
28
+ class FlakyApi:
29
+ def __init__(self, fail_times):
30
+ self.fail_times = fail_times
31
+ self.commits = []
32
+ self.token = None
33
+
34
+ def create_commit(self, repo_id, repo_type, operations, commit_message):
35
+ if self.fail_times > 0:
36
+ self.fail_times -= 1
37
+ raise ConnectionError("hub down")
38
+ assert repo_type == "dataset"
39
+ self.commits.append([op.path_in_repo for op in operations])
40
+
41
+
42
+ def test_hub_failure_keeps_spool_and_retries(tmp_path):
43
+ api = FlakyApi(fail_times=2)
44
+ s = HubParquetSink("org/data", None, spool_dir=tmp_path, api=api, flush_seconds=999)
45
+ s.add(rows(3))
46
+ assert not s.flush() and s.pending == 3 and not s.status()["healthy"]
47
+ # a restart in between must not lose rows
48
+ s2 = HubParquetSink("org/data", None, spool_dir=tmp_path, api=api, flush_seconds=999)
49
+ assert s2.pending == 3
50
+ assert not s2.flush()
51
+ assert s2.flush() and s2.pending == 0 and s2.status()["healthy"]
52
+ assert len(api.commits) == 1 and len(api.commits[0]) == 1
53
+ assert api.commits[0][0].startswith("data/events/") and api.commits[0][0].endswith(".parquet")
54
+ s3 = HubParquetSink("org/data", None, spool_dir=tmp_path, api=api, flush_seconds=999)
55
+ assert s3.pending == 0
56
+
57
+
58
+ def test_local_refuses_overwrite(tmp_path):
59
+ s = LocalParquetSink(tmp_path, flush_seconds=999)
60
+ data = rows_to_parquet_bytes(rows(1))
61
+ s._write_shard("data/events/x.parquet", data)
62
+ with pytest.raises(FileExistsError):
63
+ s._write_shard("data/events/x.parquet", data)