Spaces:
Running
Running
Deploy MLX Model Explorer (private test)
Browse files- .dockerignore +9 -0
- DEPLOY.md +76 -0
- Dockerfile +23 -2
- README.md +79 -5
- app/catalogue.py +329 -0
- app/events.py +284 -0
- app/main.py +275 -0
- app/memory.py +126 -0
- app/model_detail.py +142 -0
- app/parsing.py +398 -0
- app/ratelimit.py +45 -0
- app/recommend.py +304 -0
- app/sink.py +255 -0
- app/stats.py +147 -0
- bench/mlx_explorer_bench.py +169 -0
- data/catalogue_snapshot.json.gz +3 -0
- pytest.ini +3 -0
- requirements.txt +6 -0
- static/app.js +812 -0
- static/index.html +272 -0
- static/stats.html +42 -0
- static/stats.js +81 -0
- static/styles.css +326 -0
- static/webgpu.js +251 -0
- tests/__init__.py +0 -0
- tests/conftest.py +6 -0
- tests/test_api.py +184 -0
- tests/test_events.py +101 -0
- tests/test_memory.py +50 -0
- tests/test_parsing.py +186 -0
- tests/test_recommend.py +152 -0
- tests/test_sink.py +63 -0
.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
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|