From c84e3e13c0e355eed448ddc526c2fdf4ed71d362 Mon Sep 17 00:00:00 2001 From: Sandeep Singh <64280014+sandeeps85@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:34:25 -0500 Subject: [PATCH 1/5] docs: runbook for publishing on the Hugging Face Hub, with the model verified live Every milestone-sized piece gets a runbook, and publishing is the one outward-facing step, so it gets the same treatment: what goes where, the exact commands, and checks a stranger can run against the public API with real output (metadata, tag, SHA-256 of the weights against the export, an anonymous transformers load). It also records the pitfalls met on the way (an organisation's display name versus its username, a password manager filling the account form), because those cost real round trips. The Space section stays open until the Space is live. --- CHANGELOG.md | 2 + docs/ROADMAP.md | 5 +- docs/concepts/serving.md | 3 + docs/runbooks/README.md | 3 +- docs/runbooks/hf-hub.md | 280 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 docs/runbooks/hf-hub.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 260a1a0..6159708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ interfaces; each entry says what moved. Milestones (M0, M1, …) are described i - Model cards open with Hugging Face Hub metadata (licence, library, task, tags), so an export can be uploaded as is. A new optional Project API hook, `model_card_info()`, gives the licence, subject tags and data statement; `abc_music` and `shakespeare_char` state theirs (ADR 0010). Export code version 4. +- `abc-folk` on the Hugging Face Hub ([m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk)), + and a runbook for publishing an export there (`docs/runbooks/hf-hub.md`). ## [0.1.0] - 2026-10-13 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5d9ecac..5ed3e80 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -166,12 +166,13 @@ Goal: a public repo a newcomer can evaluate in a minute and contribute to withou ## Hugging Face Hub: the folk-tune model and a live demo ◐ Goal: anyone can load `abc-folk` with `transformers`, or try it in a browser, without installing -anything. *No GPU.* Decisions: ADR 0010. +anything. *No GPU.* Decisions: ADR 0010. Runbook: `runbooks/hf-hub.md`. - [x] Model cards carry Hub metadata, with the licence and data statement from the project (`model_card_info()`); `abc-folk:3` exported with it (runbook M2 §E.7). - [x] The demo recipe tested locally: CPU PyTorch, slmkit v0.1.0, `slm serve` (one jig in 0.36 s). -- [ ] `abc-folk:3` published on the Hub (tag `v3`), and loads there with `transformers`. +- [x] `abc-folk:3` published on the Hub (tag `v3`), byte-identical to the export, and loads there + anonymously with `transformers` (runbook H.4). - [ ] The Space is live, and the README links to it. --- diff --git a/docs/concepts/serving.md b/docs/concepts/serving.md index 89e2352..0b705fb 100644 --- a/docs/concepts/serving.md +++ b/docs/concepts/serving.md @@ -314,6 +314,9 @@ the first, and a framework slmkit doesn't otherwise use (CONTRIBUTING.md rule 6) Versions carry over: `abc-folk:3` is the model repository's tag `v3`, and the Space downloads that tag, so it keeps serving the version it was built for. +The steps, with real output and the pitfalls met on the way: [`../runbooks/hf-hub.md`](../runbooks/hf-hub.md). +The published model: [huggingface.co/m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk). + --- ## 10. What export doesn't do yet diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md index f5b3975..914d454 100644 --- a/docs/runbooks/README.md +++ b/docs/runbooks/README.md @@ -24,7 +24,8 @@ must match exactly and which are only ballpark figures. | [`m0-environment.md`](m0-environment.md) | M0 | ☑ | | [`m1-engine.md`](m1-engine.md) | M1 | ☑ | | [`m2-abc-music.md`](m2-abc-music.md) | M2 | ☑ | -| [`studio.md`](studio.md) | Studio | ◐ Phase 1 | +| [`studio.md`](studio.md) | Studio | ◐ phases 1–3 done; last criterion proved by M3 | +| [`hf-hub.md`](hf-hub.md) | Publishing on the Hugging Face Hub | ◐ model live; Space next | **In `slm studio`.** The studio lists each project's runbooks next to its runs. A runbook that belongs to one project says so near its top with `` (a comma-separated diff --git a/docs/runbooks/hf-hub.md b/docs/runbooks/hf-hub.md new file mode 100644 index 0000000..a718f1c --- /dev/null +++ b/docs/runbooks/hf-hub.md @@ -0,0 +1,280 @@ +# Runbook: publishing on the Hugging Face Hub + + + +*How an export becomes a public model that anyone can load with `transformers`, and a live demo +anyone can try in a browser. The steps are generic; the worked example is `abc-folk:3`. Concepts: +[`../concepts/serving.md`](../concepts/serving.md) §9. Decision: +[ADR 0010](../decisions/0010-publishing-to-the-hub.md). Roadmap: the "Hugging Face Hub" section of +[`../ROADMAP.md`](../ROADMAP.md).* + +| | Where | What | +|---|---|---| +| **Model** | [huggingface.co/m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk) | the export directory as is, plus `README.md` (a copy of `MODEL_CARD.md`). Tag `v3` = slmkit's `abc-folk:3` | +| **Space** | [huggingface.co/spaces/m87techlabs/abc-folk](https://huggingface.co/spaces/m87techlabs/abc-folk) | a Dockerfile that installs a released slmkit and runs `slm serve` on the model's `v3` tag | + +Publishing is the one step in slmkit that is **outward-facing and hard to take back**: once public, +files can be copied. Every step here is run by a person, and nothing in slmkit publishes on its own. + +Infrastructure analogy: the Hub is a container registry for model files, each repository a git repo. +A model version is an immutable image tag; a Space is a small hosted deployment of one image. + +--- + +## H.0 What publishing needs from slmkit + +Nothing new at run time. Two things were added so that an export can be uploaded as is (ADR 0010): + +- **The model card opens with the Hub's metadata**, a YAML block with licence, library, task and + tags. Export code version 4 and later (runbook M2 §E.7). +- **The project states the licence and the data's status** through `Project.model_card_info()`. Only + the project can know whether its weights can be licensed. `abc_music` says Apache-2.0 and states the + open question about its transcriptions (ADR 0005), with a promise to withdraw the model if anyone + objects. + +The `hf` command-line client is run through `uvx`, so it isn't a slmkit dependency (STACK.md §7). + +--- + +## H.1 Once: account, organisation, token, CLI + +1. Create a Hugging Face account, then an organisation if the models should live under a team name + (https://huggingface.co/organizations/new). +2. Create an access token with write access: Settings → Access Tokens. A classic **Write** token + covers every organisation you belong to. A **fine-grained** token needs write access granted per + organisation. +3. Log in: + +```bash +alias hf='uvx --from huggingface_hub==2.2.0 hf' +hf auth login # paste the token; "Add token as git credential?" → n +hf auth whoami +``` + +`whoami` must list the organisation under `orgs`, **by its username**. + +**Pitfalls met on the way** (each cost a round trip): + +- **An organisation has two names.** The *display name* is shown on its page; the *username* is the + namespace in every URL and command. They can differ: an organisation shown as "m87techlabs" was + first created with the username `m87admin-org`. `hf repos create m87techlabs/abc-folk` then failed + with `403 Forbidden: You don't have the rights to create a model under the namespace`. Fix: rename + the username under the organisation's Settings → Account, while it is still empty. +- **Password managers fill settings forms.** On the account settings page, a password manager filled + the *Username* field with an email address. Saving would have tried to rename the account. Check + every field before pressing Save there. +- **A 403 after that is the token**: a fine-grained token without write access to that organisation. + +--- + +## H.2 Export with the Hub metadata + +Use an export made with code version 4 or later, so its card carries the metadata. `abc-folk:3` is +the fine-tuned run from M2, exported again for this (runbook M2 §E.7): + +```bash +head -4 ~/slm/models/abc-folk/3/MODEL_CARD.md +``` + +``` +--- +license: apache-2.0 +library_name: transformers +pipeline_tag: text-generation +``` + +Before publishing, look for anything private in the files. Exports record run IDs and settings, not +paths, but check: + +```bash +grep -rIl "$HOME" ~/slm/models/abc-folk/3/ || echo "no local paths" +``` + +``` +no local paths +``` + +--- + +## H.3 Upload the model, its card and its version tag + +```bash +hf repos create m87techlabs/abc-folk --type model +hf upload m87techlabs/abc-folk ~/slm/models/abc-folk/3 . \ + --commit-message "abc-folk:3, exported by slmkit from run-9b890db6dced" +hf upload m87techlabs/abc-folk ~/slm/models/abc-folk/3/MODEL_CARD.md README.md \ + --commit-message "Model card" +hf repos tag create m87techlabs/abc-folk v3 +``` + +**Order matters:** tag *after* the README upload, so `v3` includes the card. The tag is what makes the +Hub version match slmkit's: the repository's `main` moves on when `abc-folk:4` is uploaded, but `v3` +keeps pointing at these files, and the Space downloads `v3`. + +**Why a copy, not a rename.** The Hub shows `README.md` as the model page. Exports keep the name +`MODEL_CARD.md`, which existing exports, docs and runbook output already use, so the upload copies it. + +--- + +## H.4 Verify the model, as a stranger would + +These checks use the Hub's public API and no token, so they see what anyone sees. + +**The page has its metadata:** + +```bash +curl -s https://huggingface.co/api/models/m87techlabs/abc-folk | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d['id'], 'private' if d['private'] else 'public') +print('licence:', d['cardData']['license'], '| library:', d['library_name'], '| task:', d['pipeline_tag']) +print('tags:', ', '.join(d['cardData']['tags']))" +``` + +``` +m87techlabs/abc-folk public +licence: apache-2.0 | library: transformers | task: text-generation +tags: slmkit, llama, from-scratch, abc-music, music, abc-notation, folk-music, symbolic-music +``` + +Before the README upload, the same call gave `licence` and `task` as missing: the Hub reads metadata +only from `README.md`. + +**The tag exists and points at the right commit:** + +```bash +curl -s https://huggingface.co/api/models/m87techlabs/abc-folk/refs | python3 -c " +import json, sys +d = json.load(sys.stdin) +for r in d['branches'] + d['tags']: print(r['ref'], r['targetCommit'][:12])" +``` + +``` +refs/heads/main 90441cb5f36e +refs/tags/v3 90441cb5f36e +``` + +**The published weights are byte-for-byte the export.** The Hub stores large files with their +SHA-256, so compare it with the local file: + +```bash +curl -s https://huggingface.co/api/models/m87techlabs/abc-folk/tree/v3 | python3 -c " +import json, sys +print(next(f['lfs']['oid'] for f in json.load(sys.stdin) if f['path'] == 'model.safetensors'), '(Hub, v3)')" +sha256sum ~/slm/models/abc-folk/3/model.safetensors | cut -d' ' -f1 | sed 's/$/ (local)/' +``` + +``` +0ff22be8daed1a48fc80205f4fe484774a95e2f23762e2e9ed86b2cb99024af9 (Hub, v3) +0ff22be8daed1a48fc80205f4fe484774a95e2f23762e2e9ed86b2cb99024af9 (local) +``` + +**It loads from the Hub with `transformers`**, no slmkit involved. `HF_TOKEN=` and an empty cache make +it an anonymous download: + +```bash +HF_TOKEN= HF_HUB_CACHE=$(mktemp -d) uv run --extra export python -c " +from transformers import AutoModelForCausalLM, AutoTokenizer +tok = AutoTokenizer.from_pretrained('m87techlabs/abc-folk', revision='v3') +model = AutoModelForCausalLM.from_pretrained('m87techlabs/abc-folk', revision='v3') +ids = tok('% a jig in G major, 6/8 time\n', return_tensors='pt').input_ids +print(model.num_parameters(), tok.decode(model.generate(ids, max_new_tokens=40, do_sample=False)[0])) +" +``` + +``` +864256 % a jig in G major, 6/8 time +R:jig +M:6/8 +L:1/8 +K:G +(G/A/)|BGG G2 G|BG +``` + +(After a "Loading weights" progress bar.) Greedy decoding, so this is exact: the same characters as +the local copy gives. + +--- + +## H.5 The demo: a Docker Space running `slm serve` + +**What.** A Space is a demo app built from its own git repository. A *Docker* Space builds the +repository's Dockerfile and sends visitors to one port. slmkit's Space is `slm serve`, unchanged: the +same playground page as runbook M2 §E.6, viewer and all, on the free CPU tier (2 vCPUs, 16 GB). + +The Space repository holds two files. The Dockerfile: + +``` +FROM python:3.12-slim +RUN useradd -m -u 1000 user # Spaces run containers as user 1000 +USER user +ENV HOME=/home/user PATH=/home/user/venv/bin:$PATH SLM_HOME=/home/user/slm +WORKDIR /home/user +RUN python -m venv /home/user/venv +# CPU-only PyTorch first, the version in uv.lock: slmkit's torch>=2.7 is then satisfied, +# and pip doesn't fetch the multi-GB CUDA build. +RUN pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu +ARG SLMKIT_REF=v0.1.0 +RUN pip install "slmkit @ https://github.com/m87techlabs/slmkit/archive/refs/tags/${SLMKIT_REF}.tar.gz" \ + "huggingface_hub==2.2.0" +ARG MODEL_REPO=m87techlabs/abc-folk +ARG MODEL_REV=v3 +RUN hf download "${MODEL_REPO}" --revision "${MODEL_REV}" --local-dir "${SLM_HOME}/models/abc-folk/3" +EXPOSE 7860 +CMD ["slm", "serve", "--model", "abc-folk:3", "--host", "0.0.0.0", "--port", "7860", "--threads", "2"] +``` + +(shortened here; the full file, with comments, is in the Space repository). The other file is a +`README.md` whose YAML header sets `sdk: docker`, `app_port: 7860` and `models: [m87techlabs/abc-folk]`, +which links the model page and the Space to each other. + +**How.** + +```bash +hf repos create m87techlabs/abc-folk --type space --sdk docker +hf upload m87techlabs/abc-folk
after the scripts have run."""
+ server = ThreadingHTTPServer(("127.0.0.1", 0), partial(QuietHandler, directory=str(directory)))
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ assert CHROME is not None
+ args = [CHROME, "--headless=new", "--disable-gpu", "--virtual-time-budget=60000", "--dump-dom"]
+ if not CHROME.endswith(".exe"):
+ # Ubuntu 24.04 blocks the user namespaces Chrome's sandbox needs; the page is our own.
+ args += ["--no-sandbox", f"--user-data-dir={directory.parent / 'chrome-profile'}"]
+ try:
+ url = f"http://localhost:{server.server_address[1]}/{page}"
+ dom = subprocess.run(
+ [*args, url], capture_output=True, text=True, timeout=180, check=True
+ ).stdout
+ finally:
+ server.shutdown()
+ match = re.search(r'(.*?)
', dom, re.DOTALL)
+ assert match, dom[:500]
+ result: dict[str, Any] = json.loads(html.unescape(match.group(1)))
+ assert "error" not in result, result.get("error")
+ return result
+
+
+def write_site(directory: Path, model: CausalLM, tokenizer: CharTokenizer) -> None:
+ """The browser-side files of a site, for a model that was never exported."""
+ (directory / "model").mkdir(parents=True)
+ for name in ("model.js",):
+ shutil.copy(STATIC / name, directory / name)
+ (directory / "model" / "config.json").write_text(json.dumps(hf_config(model.args)))
+ (directory / "model" / "generation_config.json").write_text(json.dumps(generation_config({})))
+ save_file(export_state_dict(model), str(directory / "model" / WEIGHTS))
+ tokenizer.to_hf().save(str(directory / "model" / "tokenizer.json"))
+
+
+@needs_chrome
+@pytest.mark.parametrize("n_kv_heads", [4, 2]) # plain multi-head, and grouped-query
+def test_browser_logits_match_pytorch(tmp_path: Path, n_kv_heads: int) -> None:
+ torch.manual_seed(0)
+ tokenizer = CharTokenizer("abcdefgh |:\n")
+ args = ModelArgs(vocab_size=tokenizer.vocab_size, block_size=32, n_layers=2, d_model=64,
+ n_heads=4, n_kv_heads=n_kv_heads, ffn_hidden=192) # fmt: skip
+ model = CausalLM(args).eval()
+ # The default init gives near-uniform logits, which would pass too easily. Scaled x3, logits
+ # span about ±5, like a trained model's. (Much larger, and float32 PyTorch disagrees with
+ # itself in float64 by more than the tolerance: x8 gives ±29 and 3e-4.)
+ with torch.no_grad():
+ for p in model.parameters():
+ p.mul_(3.0)
+ write_site(tmp_path / "site", model, tokenizer)
+ ids = torch.randint(2, args.vocab_size, (args.block_size,)).tolist() # a full context
+ page = PROBE % {"ids": json.dumps(ids), "prompt": json.dumps("ab"), "new": 1}
+ (tmp_path / "site" / "probe.html").write_text(page)
+
+ got = run_in_chrome(tmp_path / "site", "probe.html")
+ with torch.no_grad():
+ expected = model(torch.tensor([ids]))[0][0].double()
+ got_logits = torch.tensor(got["logits"], dtype=torch.float64)
+ torch.testing.assert_close(got_logits, expected, atol=1e-4, rtol=0)
+ assert got["encoded"] == tokenizer.encode("ab")
+
+
+@needs_chrome
+def test_browser_greedy_generation_matches_pytorch_past_the_context(
+ toy_run: str, tmp_path: Path
+) -> None:
+ """The trained toy model, block size 8: 24 new tokens make the browser re-read its window
+ the way generate.py crops the context."""
+ export_run(toy_run, "toy-model", 1, **QUIET)
+ exported = load_export("toy-model:1")
+ site = build_site("toy-model:1", **QUIET)
+ work = tmp_path / "copy"
+ shutil.copytree(site, work) # sites are read-only artifacts; the probe page goes in a copy
+ work.chmod(0o755)
+ prompt = "abc 1"
+ page = PROBE % {"ids": "[2, 3]", "prompt": json.dumps(prompt), "new": 24}
+ (work / "probe.html").write_text(page)
+
+ got = run_in_chrome(work, "probe.html")
+ ids = exported.tokenizer.encode(prompt)
+ out = generate(exported.model, torch.tensor([ids]), 24, temperature=0.0, stop_token=EOS_ID)
+ new = out[0, len(ids) :].tolist()
+ finished = EOS_ID in new
+ expected = exported.tokenizer.decode(new[: new.index(EOS_ID)] if finished else new)
+ assert got["greedy"]["completion"] == expected
+ assert got["greedy"]["finished"] == finished
+ assert got["greedy"]["new_tokens"] == len(new)
From 19025417d0415398f6002d955050d898322266cb Mon Sep 17 00:00:00 2001
From: Sandeep Singh <64280014+sandeeps85@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:18:14 -0500
Subject: [PATCH 4/5] docs: browser inference (concept page, ADR 0011), slm
site in the publishing runbook
browser-inference.md explains why a 0.86M-parameter model can run in a browser and a 7B one can't,
maps every piece of model.js to llama.py, works through the key/value cache (300 positions instead
of ~45,000 for a 300-token tune), how parity is proved and why the test scales its random weights
only to x3, and why a seed can't reproduce PyTorch's samples. ADR 0011 replaces ADR 0010's Docker
Space after both organisation and personal accounts returned 402. The runbook's H.5 now builds and
checks the static site, with real output.
---
CHANGELOG.md | 4 +
CONTRIBUTING.md | 3 +-
README.md | 1 +
docs/DESIGN.md | 3 +
docs/GLOSSARY.md | 12 +-
docs/MODEL.md | 4 +
docs/ROADMAP.md | 7 +-
docs/STACK.md | 15 +-
docs/concepts/browser-inference.md | 177 +++++++++++++++++++
docs/concepts/serving.md | 17 +-
docs/decisions/0010-publishing-to-the-hub.md | 4 +-
docs/decisions/0011-browser-inference.md | 66 +++++++
docs/runbooks/README.md | 2 +-
docs/runbooks/hf-hub.md | 132 ++++++++------
14 files changed, 372 insertions(+), 75 deletions(-)
create mode 100644 docs/concepts/browser-inference.md
create mode 100644 docs/decisions/0011-browser-inference.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6159708..e4076bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@ interfaces; each entry says what moved. Milestones (M0, M1, …) are described i
- Model cards open with Hugging Face Hub metadata (licence, library, task, tags), so an export can be
uploaded as is. A new optional Project API hook, `model_card_info()`, gives the licence, subject tags
and data statement; `abc_music` and `shakespeare_char` state theirs (ADR 0010). Export code version 4.
+- `slm site`: a static web site where the model runs in the visitor's browser, with no server.
+ `model.js` is the network written out in JavaScript, with a key/value cache, tested against PyTorch in
+ headless Chrome (logits within 1e-4, greedy output token for token). The playground page uses it when
+ `info` says `"engine": "browser"`; project viewers work unchanged (ADR 0011).
- `abc-folk` on the Hugging Face Hub ([m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk)),
and a runbook for publishing an export there (`docs/runbooks/hf-hub.md`).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6746819..367c70a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,7 +20,7 @@ same pipeline:
**Read before architectural work:** `docs/DESIGN.md` (architecture, contracts, rules),
`docs/MODEL.md` (the model spec: architecture, presets, file formats; keep it in sync with
-`src/slmkit/model/` and `presets/model/`) and `docs/ROADMAP.md` (current milestone and exit
+`src/slmkit/model/`, `presets/model/` and the browser copy in `src/slmkit/serve/static/model.js`) and `docs/ROADMAP.md` (current milestone and exit
criteria). Work on the current milestone only.
## Vocabulary
@@ -141,6 +141,7 @@ uv run slm runs list # tokens done/target, GPU-hours, l
uv run slm runs compare
uv run slm export --name --version
uv run slm serve --model :
+uv run slm site : # static site; the model runs in the browser
uv run slm studio start | stop | status | run # "See it in action": the local web studio
uv run slm lineage
```
diff --git a/README.md b/README.md
index 0b721c4..8354491 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ uv run slm studio start # "See it in action": everything you built, in your br
| | |
|---|---|
| [`docs/concepts/two-models.md`](docs/concepts/two-models.md) | **No music background?** What the two models do, and how to read their output |
+| [`docs/concepts/browser-inference.md`](docs/concepts/browser-inference.md) | The model in a browser: the same network in JavaScript, with no server (`slm site`) |
| `slm studio` | **See it in action.** A local web app over your runs, artifacts, models and machine ([runbook](docs/runbooks/studio.md)) |
| [`docs/LIFECYCLE.md`](docs/LIFECYCLE.md) | **Start here.** The model development lifecycle, with diagrams of the pipeline, architecture and roadmap |
| [`docs/GLOSSARY.md`](docs/GLOSSARY.md) | Every abbreviation and term, in plain language |
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 8f18490..94a0be8 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -563,6 +563,9 @@ one allowed exception is `export --to-windows`.
first project that has one (chess, M3).
- `/` is a browser playground (one static page). A project may ship a viewer (`web_viewer()`,
ADR 0008) that the export carries as `ui/`: for ABC, sheet music and in-browser playback.
+- `slm site abc-folk:3` builds a static site where the model runs in the browser: the same
+ playground page, `serve/static/model.js` (the network in JavaScript, parity-tested against PyTorch in
+ headless Chrome) and the model's files. No server at all; any static host serves it (ADR 0011).
- For anything worth keeping online, put an existing gateway in front rather than exposing
`slm serve` directly — it handles auth, rate limiting and TLS, which this app deliberately
does not.
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index b6331d2..ab35d75 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -438,8 +438,16 @@ hub. "HF format" is the de-facto standard model layout, which is why slmkit expo
repository. A model's page is its `README.md`, and a YAML block at the top gives its licence, task and
tags. The registry analogy: a container registry for model files.
-**Space.** A demo app hosted on the Hub, built from its own git repository. A *Docker* Space builds the
-repository's Dockerfile and sends visitors to one port; slmkit's runs `slm serve` (ADR 0010).
+**Space.** A demo app hosted on the Hub, from its own git repository. A *static* Space serves the
+repository's files as a web site, free; a *Docker* Space builds a Dockerfile and runs it as a server
+(now a paid plan). slmkit's demo is a static Space built by `slm site`, with the model running in the
+visitor's browser (ADR 0011).
+
+**Web Worker.** A background thread for a web page. The page and the worker exchange messages, so long
+work (here, generating text with `model.js`) doesn't freeze the page.
+
+**Static site.** A web site that is only files (HTML, JavaScript, data), served as they are, with no
+program running on the server. `slm site` builds one per model.
**safetensors.** A tensor file format that is safe to load from untrusted sources, unlike
Python's `pickle`.
diff --git a/docs/MODEL.md b/docs/MODEL.md
index 03d6ef6..6605eb6 100644
--- a/docs/MODEL.md
+++ b/docs/MODEL.md
@@ -5,6 +5,10 @@ presets, and the files a trained model ends up as. It is the specification the c
`src/slmkit/model/` implements (M1 Phase B). For terms, see [`GLOSSARY.md`](GLOSSARY.md); for the
walk-through of the actual code, `concepts/the-model.md` (lands with Phase B).*
+*A second implementation exists: `src/slmkit/serve/static/model.js` runs the same network in a browser
+(`slm site`, ADR 0011). A change to the architecture here has to be made there too;
+`tests/unit/test_site.py` fails if the two disagree.*
+
---
## At a glance
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 5ed3e80..a6f0b82 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -170,10 +170,13 @@ anything. *No GPU.* Decisions: ADR 0010. Runbook: `runbooks/hf-hub.md`.
- [x] Model cards carry Hub metadata, with the licence and data statement from the project
(`model_card_info()`); `abc-folk:3` exported with it (runbook M2 §E.7).
-- [x] The demo recipe tested locally: CPU PyTorch, slmkit v0.1.0, `slm serve` (one jig in 0.36 s).
+- [x] The Docker recipe tested locally: CPU PyTorch, slmkit v0.1.0, `slm serve` (one jig in 0.36 s).
+ Not used: Docker Spaces need a paid plan.
- [x] `abc-folk:3` published on the Hub (tag `v3`), byte-identical to the export, and loads there
anonymously with `transformers` (runbook H.4).
-- [ ] The Space is live, and the README links to it.
+- [x] `slm site`: the model runs in the browser from static files, parity-tested against PyTorch in
+ headless Chrome (ADR 0011). Docker Spaces turned out to need a paid plan; static Spaces are free.
+- [ ] The static Space is live, and the README links to it.
---
diff --git a/docs/STACK.md b/docs/STACK.md
index 604a807..ea3560d 100644
--- a/docs/STACK.md
+++ b/docs/STACK.md
@@ -358,7 +358,7 @@ Triton Inference Server, which are built for fleets of GPU models, not one 3.5 M
### Hugging Face Hub, Spaces and the `hf` CLI
**◐** (after v0.1.0; not a dependency) · `huggingface_hub 2.2` via `uvx` ·
-[Hub](https://huggingface.co/docs/hub) · [Docker Spaces](https://huggingface.co/docs/hub/spaces-sdks-docker) ·
+[Hub](https://huggingface.co/docs/hub) · [static Spaces](https://huggingface.co/docs/hub/spaces-sdks-static) ·
[CLI](https://huggingface.co/docs/huggingface_hub/guides/cli)
**What it is.** Hugging Face's hosting for models and demo apps, each a git repository, and the
@@ -366,9 +366,10 @@ command-line client that uploads to it.
**Why.** Publishing an export where anyone can load it with `transformers` or try it in a browser.
Exports are already HF-format and their cards carry the Hub's metadata (ADR 0010), so publishing is
-`hf upload`. The demo is a *Docker* Space running `slm serve` itself. Run through `uvx`, the CLI
-installs nothing into slmkit. Rejected: a Gradio Space (a second UI, and a framework slmkit doesn't
-use); making `huggingface_hub` a dependency for an occasional, manual step.
+`hf upload`. The demo is a *static* Space holding what `slm site` builds, with the model running in
+the visitor's browser (ADR 0011). Run through `uvx`, the CLI installs nothing into slmkit. Rejected: a
+Docker Space running `slm serve` (it now needs a paid plan); a Gradio Space (a second UI, and a framework
+slmkit doesn't use); making `huggingface_hub` a dependency for an occasional, manual step.
### HTTPX
**●** extra `dev` · `0.28` · [docs](https://www.python-httpx.org/)
@@ -400,6 +401,12 @@ zoom and axis ticks are exactly the parts worth not writing).
studio and playground page is the check that the JavaScript works. The runbooks' screenshots were made
this way (studio.md §S.6). Nothing is installed; both browsers ship with Windows.
+`tests/unit/test_site.py` also uses it (`--dump-dom`) to run `model.js`, the in-browser copy of the
+model, against PyTorch (ADR 0011). It finds `google-chrome` or `chromium` on Linux (CI's Ubuntu runners
+have Chrome) or the Windows browsers from WSL, honours `SLM_CHROME`, and skips if there is none.
+Headless Chrome's virtual time doesn't advance Web Workers, so screenshots of the browser engine
+generating were taken over the DevTools protocol instead, with a throwaway script outside the repo.
+
### Docker and Kubernetes
**◐ M4** (optional) · [Docker](https://docs.docker.com/) · [Kubernetes](https://kubernetes.io/docs/) ·
[kind](https://kind.sigs.k8s.io/) · [k3s](https://docs.k3s.io/)
diff --git a/docs/concepts/browser-inference.md b/docs/concepts/browser-inference.md
new file mode 100644
index 0000000..2f4e8ad
--- /dev/null
+++ b/docs/concepts/browser-inference.md
@@ -0,0 +1,177 @@
+# The model in a browser: inference with no server
+
+*After v0.1.0. Code: [`src/slmkit/serve/static/model.js`](../../src/slmkit/serve/static/model.js) (the
+network), [`engine-worker.js`](../../src/slmkit/serve/static/engine-worker.js) (runs it off the page's
+thread), [`src/slmkit/serve/site.py`](../../src/slmkit/serve/site.py) (`slm site`). Tests:
+[`tests/unit/test_site.py`](../../tests/unit/test_site.py). Decision:
+[ADR 0011](../decisions/0011-browser-inference.md). Hands-on:
+[`../runbooks/hf-hub.md`](../runbooks/hf-hub.md) §H.5. Terms: [`../GLOSSARY.md`](../GLOSSARY.md).*
+
+`slm serve` runs a model on a server: a browser sends a prompt, Python and PyTorch compute, the text
+comes back. For the models slmkit trains, there is a simpler option. The **browser** can do the
+computing itself, so the "server" is just a folder of static files. `slm site abc-folk:3` builds that
+folder.
+
+The infrastructure analogy: moving work from an API server to the client, the way a single-page app
+moved rendering into the browser. It only works when the client can afford the work, and here it can:
+abc-folk has 864,256 parameters and its weights are a 3.5 MB file, smaller than many web pages' images.
+
+---
+
+## 1. Why it's possible here, and not for big models
+
+Generating one token means multiplying the current state through every weight matrix once: about **one
+multiply-add per parameter**, plus attention over the tokens so far. For abc-folk that is under a million
+multiply-adds per token, which plain JavaScript does in well under a millisecond.
+
+| | abc-folk | a 7-billion-parameter chat model |
+|---|---|---|
+| weights to download | 3.5 MB | ~14 GB (16-bit) |
+| work per token | ~0.9 M multiply-adds | ~7,000 M |
+| in a browser, plain JavaScript | **~1,500 tokens/s, measured** | impractical |
+
+Big models in browsers exist, but they need the GPU (WebGPU), weights squeezed to 4 bits, and a large
+download. slmkit's models need none of that.
+
+**Measured:** in Chrome on the reference machine, one jig (189 tokens) in 0.12 s, about **1,500
+tokens/s**. `slm serve` on the CPU makes the same tune at about **450 tokens/s**. The browser is faster
+because at this size PyTorch's per-operation overhead, not arithmetic, dominates (serving.md §6).
+
+---
+
+## 2. The same network, written a second time
+
+`model.js` is `src/slmkit/model/llama.py` again, in plain JavaScript with no library. It reads an
+export's own files: `config.json` for the sizes, `tokenizer.json` for the characters, and
+`model.safetensors` for the weights. That last format is simple enough to read by hand: an 8-byte
+length, a JSON header naming each tensor's shape and byte range, then raw float32 numbers
+(MODEL.md §6).
+
+Every piece maps to a class in `llama.py`, and each has to match exactly, or the logits differ:
+
+| llama.py | model.js | the detail that has to match |
+|---|---|---|
+| `RMSNorm` | `rmsnorm()` | the epsilon from `config.json`, the learned gain, no mean subtraction |
+| `RotaryEmbedding` | `rope()`, tables in the constructor | pairs `(i, i + Hd/2)` ("rotate_half"), and angles rounded to float32 as PyTorch computes them |
+| `Attention` | the head loop in `step()` | query head `h` reads key/value head `h ÷ (H/K)` (grouped-query) and scales by `1/√Hd` |
+| `MLP` | the SwiGLU lines in `step()` | `down(silu(gate(x)) × up(x))` |
+| tied `lm_head` | `this.head = this.embed` | the output layer *is* the embedding matrix, stored once |
+| `filter_logits` | `filterLogits()` | temperature, then top-k, then top-p, in that order |
+
+A linear layer is the one operation everything else is built from. In PyTorch it is `x @ W.T`; here it
+is a loop:
+
+```js
+function matvec(W, x, out, rows, cols) { // W is [rows, cols], row-major, as nn.Linear stores it
+ for (let r = 0; r < rows; r++) {
+ let sum = 0;
+ for (let c = 0; c < cols; c++) sum += W[r * cols + c] * x[c];
+ out[r] = sum;
+ }
+}
+```
+
+The weights stay float32, as stored. The arithmetic runs in JavaScript's float64, which is at least as
+precise as PyTorch's float32, so results agree to rounding.
+
+---
+
+## 3. The key/value cache: why each token costs one position
+
+`llama.py`'s sampler re-runs the whole context for every new token. On a GPU that costs nothing worth
+saving, and the loop stays easy to read. In JavaScript it would be wasteful. For a 300-token tune, the
+network would process 1 + 2 + … + 300 ≈ **45,000 positions** to produce 300 tokens.
+
+The fix is the standard one. In attention, a position's *key* and *value* vectors depend only on that
+position and the ones before it. They never change once computed, so `model.js` stores them. Each new
+token then runs the network for **one position**, and reads the stored keys and values of all earlier
+ones. That is 300 positions instead of 45,000.
+
+The cache is `layers × positions × kv heads × head size` numbers for keys, and again for values. For
+abc-folk that is 4 × 512 × 4 × 32 × 2 × 8 bytes ≈ **4 MB**. It is kept in float64, like the rest of the
+arithmetic, so it adds no rounding of its own.
+
+**At the context limit.** abc-folk sees at most 512 tokens. When the text passes that, `generate.py`
+keeps the last 512 tokens, and they take positions 0 to 511 again. Every cached key was rotated for
+its old position, so the cache is no longer valid. `model.js` does the same as `generate.py`: it
+re-reads the last 512 tokens from scratch. That costs 512 positions instead of 1 for each token past
+the limit, but most tunes end well before it.
+
+---
+
+## 4. Proving it is the same model
+
+A second implementation is only useful if it computes the same function. The test does what
+`slm export` does with `transformers` (serving.md §4), in a real browser. `tests/unit/test_site.py`
+serves a model to headless Chrome, runs `model.js`, and compares with PyTorch:
+
+- **logits at every position of a full context, within 1e-4**, for plain multi-head and for
+ grouped-query attention;
+- **greedy generation, token for token**, for 24 tokens on a model with an 8-token context, so the
+ re-reading in §3 is exercised.
+
+On the real abc-folk:3, the largest logit difference is **2.4e-6**. Greedy output from the Hub's
+`transformers`, from `slm serve` and from the browser is the same text.
+
+**A test that can't fail proves nothing.** A freshly initialised model gives nearly uniform logits: every
+token scores about the same, so even a wrong implementation would land within 1e-4. The test scales the
+random weights so that the logits span about ±5, like a trained model's. It doesn't scale them much
+further. At ×8, logits reach ±29, and PyTorch itself, run in float32 and in float64, disagrees by 3e-4,
+more than the tolerance.
+
+---
+
+## 5. Seeds: the same seed, a different tune
+
+With temperature 0 (greedy), the browser and the server write the same text. With sampling, they don't:
+`seed 3` in the browser gives a different tune than `seed 3` from `slm serve`.
+
+A seed selects a sequence of random numbers, and the sequence comes from a specific generator. PyTorch's
+is a Mersenne Twister with its own way of turning bits into a sampled token (`torch.multinomial`).
+`model.js` uses a small generator (mulberry32) and samples by walking the cumulative probabilities.
+Reproducing PyTorch's generator bit for bit in JavaScript would be possible, but it would be a
+reimplementation of PyTorch internals for no benefit to anyone. So a seed **reproduces a result within
+the browser** (the playground's "Copy link" still works), and the distribution the tokens are drawn from
+is the same.
+
+---
+
+## 6. The page doesn't change
+
+The playground page (serving.md §8) asks for `info`, then posts to `generate`. A site built by
+`slm site` has a static file named `info` with two extra fields: `"engine": "browser"` and
+`"files": "model/"`. When the page sees them, it starts `engine-worker.js` (a **Web Worker**: a
+background thread for a page, so the page stays responsive while it computes) and sends the request
+there instead. The worker returns the same JSON that `POST /generate` would, field for field, so the
+page, its statistics and the project's viewer (sheet music and playback for ABC) work unchanged.
+
+```
+$SLM_HOME/sites/site-5c053f5e701f/
+├── index.html the playground page, as slm serve sends it
+├── model.js the network (§2, §3)
+├── engine-worker.js runs model.js off the page's thread
+├── info what /info returns, plus "engine": "browser"
+├── model/ config.json, generation_config.json, tokenizer.json, model.safetensors
+├── ui/viewer.js the project's viewer, from the export
+└── manifest.json which model, which code
+```
+
+A site is an artifact: its ID hashes the model and the code version, it is written once, and
+`slm lineage site-5c053f5e701f` walks from it back to the raw data. Any static host serves it: a
+Hugging Face static Space (§H.5 of the runbook), GitHub Pages, or `python3 -m http.server`.
+
+---
+
+## 7. What it doesn't do yet
+
+- **BPE models.** `model.js` reads slmkit's character-level tokenizer only. A BPE tokenizer needs its
+ merges applied in JavaScript; `slm site` refuses such models with a clear message until then.
+- **Constrained decoding.** As with `slm serve`, a project's `logits_processor` is Python and doesn't
+ run here (serving.md §10).
+- **Models much larger than ~10M parameters.** Nothing stops them, but the download and the work per
+ token grow with the parameter count; beyond tens of millions, a server or WebGPU is the better tool.
+
+**Rejected:** transformers.js with an ONNX export. It would add a second model format and a library
+standing in for exactly the part slmkit exists to write out. **Rejected:** WebGPU. It's needless at this
+size, and not every browser has it. **Rejected:** a Docker Space running `slm serve`, the first plan.
+It works, but Hugging Face now charges for Docker Spaces, and a server is more than a 3.5 MB model needs.
diff --git a/docs/concepts/serving.md b/docs/concepts/serving.md
index 0b705fb..51dbc24 100644
--- a/docs/concepts/serving.md
+++ b/docs/concepts/serving.md
@@ -301,18 +301,21 @@ says Apache-2.0 *and* states the open question about its transcriptions (ADR 000
withdraw the model if anyone objects. A project that says nothing gets no licence on its card rather
than an invented one.
-**The demo is `slm serve`, unchanged.** A Hugging Face *Space* is a demo app built from its own git
-repository; a Docker Space builds a Dockerfile and sends visitors to one port. slmkit's Space installs
-CPU-only PyTorch and a released slmkit, downloads the model by version tag, and runs
-`slm serve --host 0.0.0.0 --port 7860`. The page visitors see is the playground from §8, viewer and
-all. Measured on the same recipe in a clean environment: one jig, 160 tokens, in 0.36 s on a CPU.
+**The demo is the same playground, with no server.** A Hugging Face *Space* is a demo app hosted from
+its own git repository. The first plan was a Docker Space running `slm serve`. It worked when tested
+locally (one jig in 0.36 s on a CPU), but Hugging Face now charges for Docker Spaces. A *static* Space,
+which only serves files, is free. slmkit's models are small enough for the browser to run them, so
+`slm site abc-folk:3` builds the playground page plus `model.js`, the same network written in
+JavaScript, and the model's files. Each visitor's browser generates the tunes, at about 1,500
+tokens/s. [`browser-inference.md`](browser-inference.md) explains how, and ADR 0011 records the
+decision.
**Rejected:** a Gradio app, the Hub's usual choice. It would be a second interface to keep in step with
the first, and a framework slmkit doesn't otherwise use (CONTRIBUTING.md rule 6). **Rejected:** renaming
`MODEL_CARD.md` to `README.md`; the upload copies it instead.
-Versions carry over: `abc-folk:3` is the model repository's tag `v3`, and the Space downloads that tag,
-so it keeps serving the version it was built for.
+Versions carry over: `abc-folk:3` is the model repository's tag `v3`, and the site for it is built from
+that export, so the demo shows the version it was built for.
The steps, with real output and the pitfalls met on the way: [`../runbooks/hf-hub.md`](../runbooks/hf-hub.md).
The published model: [huggingface.co/m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk).
diff --git a/docs/decisions/0010-publishing-to-the-hub.md b/docs/decisions/0010-publishing-to-the-hub.md
index 42780ec..2d2ea6e 100644
--- a/docs/decisions/0010-publishing-to-the-hub.md
+++ b/docs/decisions/0010-publishing-to-the-hub.md
@@ -1,6 +1,8 @@
# ADR 0010 — Publishing models to the Hugging Face Hub, and the `model_card_info` hook
-**Status:** accepted (after v0.1.0)
+**Status:** accepted (after v0.1.0). Decision 5, the demo as a Docker Space, is replaced by
+[ADR 0011](0011-browser-inference.md): Docker Spaces turned out to need a paid plan, so the demo is a
+static site with the model running in the browser.
## Context
diff --git a/docs/decisions/0011-browser-inference.md b/docs/decisions/0011-browser-inference.md
new file mode 100644
index 0000000..a3c7bd9
--- /dev/null
+++ b/docs/decisions/0011-browser-inference.md
@@ -0,0 +1,66 @@
+# ADR 0011 — `slm site`: the model runs in the browser, served as static files
+
+**Status:** accepted (after v0.1.0). Replaces the demo part of ADR 0010 (decision 5): the online demo is
+a static site, not a Docker Space running `slm serve`.
+
+## Context
+
+ADR 0010 planned the online demo as a Hugging Face Docker Space running `slm serve`. Creating it failed
+with `402 Payment Required`. Hugging Face charges for Docker and Gradio Spaces, even on the free CPU tier:
+organisations need a Team plan, personal accounts need PRO. Static Spaces, which serve files only, stay
+free for everyone.
+
+A static page can still serve a model if the browser does the computing. slmkit's models are small: the
+demo model, abc-folk, has 864,256 parameters (3.5 MB of weights) and needs under a million multiply-adds
+per token.
+
+## Decision
+
+1. **`src/slmkit/serve/static/model.js`: the network, written out in JavaScript.** It reads an export's
+ own files (`config.json`, `tokenizer.json`, `model.safetensors`) and computes what `model/llama.py`
+ computes: RMSNorm, rotate-half RoPE with float32 angle tables, grouped-query attention, SwiGLU, a tied
+ head. It has a key/value cache, and when the context passes `block_size` it re-reads the last block,
+ as `generate.py` crops. Sampling (temperature, top-k, top-p) mirrors `filter_logits`. No library, no
+ build step.
+2. **The playground page gains one branch.** When its `info` says `"engine": "browser"`, it generates in
+ a Web Worker (`engine-worker.js`) and receives the same JSON as `POST /generate`. Everything else on
+ the page, and every project viewer (ADR 0008), works unchanged.
+3. **`slm site `** writes `$SLM_HOME/sites/site-/`: the page, `model.js`, the worker,
+ a static `info`, the model's files and its viewer. It is an artifact, content-addressed by model and
+ code version, immutable, with lineage back to the raw data. Character-level models only for now; BPE
+ models are refused.
+4. **Parity is a test, as for export.** `tests/unit/test_site.py` runs `model.js` in headless Chrome
+ against PyTorch: logits at every position within 1e-4 (multi-head and grouped-query), and greedy
+ generation token for token past the context length. The test is skipped when no Chrome or Chromium is
+ found; CI's Ubuntu runners have Chrome.
+5. **The demo is a static Space under the organisation**, holding the site that `slm site abc-folk:3`
+ built.
+
+## Consequences
+
+- The demo costs nothing and has nothing to run, sleep, scale or patch. Each visitor's browser does the
+ work: ~1,500 tokens/s measured in Chrome, faster than `slm serve` on a CPU (~450 tokens/s), where
+ PyTorch's per-operation overhead dominates at this size.
+- **slmkit now has two implementations of its model.** `llama.py` stays the specification (MODEL.md),
+ and `model.js` has to follow any change to it. The parity test fails if it doesn't: a change to
+ RoPE, norms or attention in one and not the other moves logits far beyond 1e-4.
+- With sampling, a seed reproduces a result within the browser, not across engines: JavaScript can't
+ replay PyTorch's random number generator. Greedy output is identical everywhere.
+- The site is the same files whatever hosts it: a static Space, GitHub Pages, or `python3 -m http.server`.
+
+## Alternatives considered
+
+- **Pay for a Docker Space** (PRO or Team): rejected. It's a recurring cost for a demo whose model fits
+ in a browser, and it adds a server to keep running.
+- **transformers.js with an ONNX export:** rejected. It adds a second model format, a conversion step and
+ a large library, all standing in for the part slmkit writes out on purpose (CONTRIBUTING.md rule 6 in
+ spirit).
+- **WebGPU:** rejected for now. It's needless at under 10M parameters, and not every browser has it.
+- **No key/value cache, like `generate.py`:** rejected. A 300-token tune would cost ~45,000 positions
+ instead of 300, which is seconds rather than milliseconds in JavaScript.
+- **An embedded JavaScript engine (V8 for Python) for the parity test:** rejected. It's a new
+ dependency, and it would test an engine visitors don't use. Headless Chrome is the real target and is
+ already how slmkit checks its pages (STACK.md).
+- **Downloading the weights from the model repository at page load,** instead of copying them into the
+ site: rejected. A site that carries its files works on any host and offline, and its parity was tested
+ on exactly those bytes.
diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md
index 914d454..dbafd98 100644
--- a/docs/runbooks/README.md
+++ b/docs/runbooks/README.md
@@ -25,7 +25,7 @@ must match exactly and which are only ballpark figures.
| [`m1-engine.md`](m1-engine.md) | M1 | ☑ |
| [`m2-abc-music.md`](m2-abc-music.md) | M2 | ☑ |
| [`studio.md`](studio.md) | Studio | ◐ phases 1–3 done; last criterion proved by M3 |
-| [`hf-hub.md`](hf-hub.md) | Publishing on the Hugging Face Hub | ◐ model live; Space next |
+| [`hf-hub.md`](hf-hub.md) | Publishing on the Hugging Face Hub, and `slm site` | ◐ model live; static Space next |
**In `slm studio`.** The studio lists each project's runbooks next to its runs. A runbook that belongs
to one project says so near its top with `` (a comma-separated
diff --git a/docs/runbooks/hf-hub.md b/docs/runbooks/hf-hub.md
index 3fb2ff0..32c9a68 100644
--- a/docs/runbooks/hf-hub.md
+++ b/docs/runbooks/hf-hub.md
@@ -4,14 +4,15 @@
*How an export becomes a public model that anyone can load with `transformers`, and a live demo
anyone can try in a browser. The steps are generic; the worked example is `abc-folk:3`. Concepts:
-[`../concepts/serving.md`](../concepts/serving.md) §9. Decision:
-[ADR 0010](../decisions/0010-publishing-to-the-hub.md). Roadmap: the "Hugging Face Hub" section of
+[`../concepts/serving.md`](../concepts/serving.md) §9 and
+[`../concepts/browser-inference.md`](../concepts/browser-inference.md). Decisions:
+[ADR 0010](../decisions/0010-publishing-to-the-hub.md), [ADR 0011](../decisions/0011-browser-inference.md). Roadmap: the "Hugging Face Hub" section of
[`../ROADMAP.md`](../ROADMAP.md).*
| | Where | What |
|---|---|---|
| **Model** | [huggingface.co/m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk) | the export directory as is, plus `README.md` (a copy of `MODEL_CARD.md`). Tag `v3` = slmkit's `abc-folk:3` |
-| **Space** | [huggingface.co/spaces/m87admin/abc-folk](https://huggingface.co/spaces/m87admin/abc-folk) | a Dockerfile that installs a released slmkit and runs `slm serve` on the model's `v3` tag |
+| **Space** | [huggingface.co/spaces/m87techlabs/abc-folk](https://huggingface.co/spaces/m87techlabs/abc-folk) | a **static** Space: the site `slm site abc-folk:3` builds. The model runs in each visitor's browser; there is no server |
Publishing is the one step in slmkit that is **outward-facing and hard to take back**: once public,
files can be copied. Every step here is run by a person, and nothing in slmkit publishes on its own.
@@ -64,11 +65,11 @@ hf auth whoami
the *Username* field with an email address. Saving would have tried to rename the account. Check
every field before pressing Save there.
- **A 403 after that is the token**: a fine-grained token without write access to that organisation.
-- **Organisations pay for Docker Spaces.** Creating the Space under the organisation returned
- `402 Payment Required: … hosting Gradio and Docker Spaces on free cpu-basic requires a Team or
- Enterprise plan for organization m87techlabs`. Static Spaces (HTML only) stay free for organisations,
- and personal accounts can still run a Docker Space on the free CPU tier. So the Space lives under the
- personal account `m87admin` and serves the organisation's model (H.5).
+- **Docker Spaces aren't free any more.** Creating a Docker Space returned `402 Payment Required`,
+ under the organisation (`… requires a Team or Enterprise plan for organization m87techlabs`) and
+ under the personal account (`… requires a PRO subscription`). Static Spaces, which serve files only,
+ are free for everyone. That is why the demo became a static site with the model running in the
+ browser (ADR 0011, H.5).
---
@@ -201,78 +202,94 @@ the local copy gives.
---
-## H.5 The demo: a Docker Space running `slm serve`
+## H.5 The demo: a static Space, the model running in the browser
-**What.** A Space is a demo app built from its own git repository. A *Docker* Space builds the
-repository's Dockerfile and sends visitors to one port. slmkit's Space is `slm serve`, unchanged: the
-same playground page as runbook M2 §E.6, viewer and all, on the free CPU tier (2 vCPUs, 16 GB).
+**What.** `slm site abc-folk:3` builds a folder of static files: the playground page from runbook M2
+§E.6, `model.js` (the same network as `llama.py`, in JavaScript), the model's files and the folk-tune
+viewer. A static Space serves the folder as it is, and each visitor's browser runs the model.
+`../concepts/browser-inference.md` explains how and why.
-The Space repository holds two files. The Dockerfile:
+**Build the site.** It is an artifact like any other, so building it again is a no-op:
+
+```bash
+uv run slm site abc-folk:3
+ls ~/slm/sites/site-5c053f5e701f ~/slm/sites/site-5c053f5e701f/model
+```
```
-FROM python:3.12-slim
-RUN useradd -m -u 1000 user # Spaces run containers as user 1000
-USER user
-ENV HOME=/home/user PATH=/home/user/venv/bin:$PATH SLM_HOME=/home/user/slm
-WORKDIR /home/user
-RUN python -m venv /home/user/venv
-# CPU-only PyTorch first, the version in uv.lock: slmkit's torch>=2.7 is then satisfied,
-# and pip doesn't fetch the multi-GB CUDA build.
-RUN pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu
-ARG SLMKIT_REF=v0.1.0
-RUN pip install "slmkit @ https://github.com/m87techlabs/slmkit/archive/refs/tags/${SLMKIT_REF}.tar.gz" \
- "huggingface_hub==2.2.0"
-ARG MODEL_REPO=m87techlabs/abc-folk
-ARG MODEL_REV=v3
-RUN hf download "${MODEL_REPO}" --revision "${MODEL_REV}" --local-dir "${SLM_HOME}/models/abc-folk/3"
-EXPOSE 7860
-CMD ["slm", "serve", "--model", "abc-folk:3", "--host", "0.0.0.0", "--port", "7860", "--threads", "2"]
+abc-folk:3: site already built at /home/you/slm/sites/site-5c053f5e701f
+try it: python3 -m http.server -d /home/you/slm/sites/site-5c053f5e701f 8000 then open http://localhost:8000/
+/home/you/slm/sites/site-5c053f5e701f:
+engine-worker.js
+index.html
+info
+manifest.json
+model
+model.js
+ui
+
+/home/you/slm/sites/site-5c053f5e701f/model:
+config.json
+generation_config.json
+model.safetensors
+tokenizer.json
```
-(shortened here; the full file, with comments, is in the Space repository). The other file is a
-`README.md` whose YAML header sets `sdk: docker`, `app_port: 7860` and `models: [m87techlabs/abc-folk]`,
-which links the model page and the Space to each other.
+(The first build prints `built site-5c053f5e701f for abc-folk:3 -> …`.) Try it locally with the
+`http.server` line it prints: the page header ends in "· your browser", and Generate needs no server
+beyond the one handing out files.
-**How.**
+**It is the same model.** The parity tests run `model.js` in headless Chrome against PyTorch (skipped if
+no Chrome or Chromium is found; set `SLM_CHROME` to point at one):
```bash
-hf repos create m87admin/abc-folk --type space --sdk docker
-hf upload m87admin/abc-folk . --type space \
- --commit-message "slm serve (slmkit v0.1.0) for abc-folk:3"
+uv run pytest -q tests/unit/test_site.py
```
-It is created under the personal account: organisations need a paid plan for Docker Spaces (H.1).
-The Space's README still names the organisation's model, so the two pages link to each other. The first build takes a
-few minutes; its log is at `https://huggingface.co/spaces/m87admin/abc-folk?logs=build`.
+```
+6 passed
+```
+
+Measured by hand on abc-folk:3: the largest logit difference over a full prompt is 2.4e-6, greedy output
+is the same text as `transformers` (H.4) and `slm serve`, and a 189-token jig takes 0.12 s in Chrome
+(about 1,500 tokens/s).
-**Verify.** The recipe was first run by hand in a clean virtual environment (CPU PyTorch 2.11.0, slmkit
-from the v0.1.0 tarball, `slm serve --model abc-folk:3`): one jig of 160 tokens in 0.36 s, 446 tokens/s.
-Against the live Space:
+**Publish it.** Create a static Space, upload the site, then a `README.md` whose YAML header says
+`sdk: static` and `app_file: index.html` and names the model (`models: [m87techlabs/abc-folk]`), which
+links the Space and the model page to each other:
```bash
-curl -s https://m87admin-abc-folk.hf.space/health
+hf repos create m87techlabs/abc-folk --type space --sdk static
+hf upload m87techlabs/abc-folk ~/slm/sites/site-5c053f5e701f . --type space \
+ --commit-message "slm site abc-folk:3"
+hf upload m87techlabs/abc-folk README.md --type space \
+ --commit-message "Space card"
```
+A model and a Space can share a name: they are different repository types. A static Space has no build
+step; it is live within a minute.
+
**Why it's built this way.**
-- **`slm serve`, not a Gradio app** (the Hub's default): a second interface would need keeping in step
- with the first, and Gradio is a framework slmkit doesn't use (CONTRIBUTING.md rule 6).
-- **A released slmkit and a tagged model**, both pinned: rebuilding the Space later gives the same
- thing, the way a pinned base image does.
-- **CPU-only PyTorch, installed first.** The default PyTorch from PyPI bundles CUDA libraries a CPU
- Space can't use.
-- **The Dockerfile lives in the Space's repository, not slmkit's.** It deploys one model of one
- project, and container design is M4's work (ADR 0010).
+- **No server:** a Docker Space needs a paid plan (H.1), and a server is more than a 3.5 MB model needs.
+ The browser generates faster than `slm serve` on a CPU, because at this size PyTorch's per-operation
+ overhead dominates.
+- **The site carries its own copy of the model files** rather than fetching them from the model
+ repository: it works on any static host, and its parity was tested on exactly those bytes.
+- **The same page as `slm serve`:** one playground to maintain, and project viewers work in both.
+- **Rejected:** a Gradio app (a second interface and a framework slmkit doesn't use);
+ transformers.js with an ONNX export (a second model format standing in for the part slmkit writes
+ itself). See ADR 0011.
---
## H.6 Updating, and taking it down
-- **A new version** (`abc-folk:4`): upload its directory and card to the same model repository, tag
- it `v4`, then change `MODEL_REV` and the model version in the Space's Dockerfile and upload that.
- `v3` stays downloadable.
+- **A new version** (`abc-folk:4`): upload its directory and card to the same model repository and
+ tag it `v4`. Then `slm site abc-folk:4`, and upload that site to the Space. `v3` stays downloadable
+ from the model repository, and the Space's git history keeps the old site.
- **Withdrawal**, if a rights-holder objects to the data (ADR 0005), is one command per repository:
- `hf repos delete m87admin/abc-folk --type space` and `hf repos delete m87techlabs/abc-folk`.
+ `hf repos delete m87techlabs/abc-folk --type space` and `hf repos delete m87techlabs/abc-folk`.
---
@@ -281,6 +298,7 @@ curl -s https://m87admin-abc-folk.hf.space/health
- [x] `abc-folk:3` is public on the Hub with its card, licence, task and tags, and tag `v3` (H.3, H.4).
- [x] The Hub's weights are byte-identical to the export, and `transformers` loads them anonymously and
generates the expected tune (H.4).
-- [ ] The Space builds and answers `/health`; the playground draws and plays a tune (H.5).
+- [x] `slm site` builds the static site, and `model.js` matches PyTorch in Chrome (H.5).
+- [ ] The static Space is live; the playground draws and plays a tune there (H.5).
- [ ] The README links to the Space.
- [ ] **You** have run H.4 and opened both pages.
From 28ed021788dd62c61284ec899675a7e66d1b7019 Mon Sep 17 00:00:00 2001
From: Sandeep Singh <64280014+sandeeps85@users.noreply.github.com>
Date: Wed, 14 Oct 2026 20:51:12 -0500
Subject: [PATCH 5/5] docs(changelog): credit the runs list --project filter
(#8)
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4076bd..690785b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ interfaces; each entry says what moved. Milestones (M0, M1, …) are described i
`model.js` is the network written out in JavaScript, with a key/value cache, tested against PyTorch in
headless Chrome (logits within 1e-4, greedy output token for token). The playground page uses it when
`info` says `"engine": "browser"`; project viewers work unchanged (ADR 0011).
+- `slm runs list --project NAME` lists one project's runs (#8, thanks @KeuqS).
- `abc-folk` on the Hugging Face Hub ([m87techlabs/abc-folk](https://huggingface.co/m87techlabs/abc-folk)),
and a runbook for publishing an export there (`docs/runbooks/hf-hub.md`).