This is the wasmtune demo. The page below is a throwaway static docs site. The chat widget in the corner is the real <site-chat> element, running on your GPU with no server behind it.

Lumen — time-series store for edge devices

Lumen ingests metrics from constrained devices and keeps them queryable for 13 months. This documentation site is ordinary static HTML. The assistant in the corner is not: it runs a language model in this tab, on this GPU, with no inference server anywhere.

Quickstart

Install the SDK and point it at a project. There is no server to deploy.

npm i @lumen/sdk
lumen init --project my-project
lumen push --config lumen.config.ts

lumen init writes a project manifest, generates a write key scoped to that project, and creates the ingest endpoint. Keys are shown once and stored in ~/.config/lumen/credentials.json.

Write API

Send one JSON object per measurement. Batches are newline-delimited JSON, capped at 1000 records.

curl https://ingest.lumen.dev/v2/write \
  -H "Authorization: Bearer $LUMEN_KEY" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @metrics.ndjson

A 200 means the batch was durably queued, not that every point parsed. Per-record rejections arrive in the /v2/ingest-report response and in the project log.

Rate limits

Limits are per project, per minute, and burst is limit × 1.5. Exceeding either returns 429 with a Retry-After header. Free projects are capped at 1,000 writes and 30 days of retention; paid projects raise both on request.

Billing

Usage is billed per million points ingested, monthly, in arrears. There is no egress charge and no charge for queries. Invoices are issued on the first of the month and cover the previous calendar month. Deleting a project removes its points immediately; the invoice is not adjusted.

Troubleshooting

Writes return 202 but no data appears. Query the same window with ?consistency=strong; the default is eventual and can lag a few seconds.

Device clocks drift. Lumen rejects points more than 24 hours in the past. Send X-Lumen-Offset-Ms to correct for a known device offset.

Model-tier selection

This is the part that is hard to fake. The manifest below ships six model tiers; the browser measures its own hardware and loads the biggest one it can actually run. Nothing about the visitor is configured in advance.

Your hardware detected in this tab

Shipped tiers biggest that fits gets loaded

    Put this on your own site

    A couple of commands. Point dataDir at your content folder and the pipeline crawls it (HTML becomes Markdown, not a tag-free word stream).

    npx wasmtune dataset     # crawl dataDir -> QA-heavy training set
    npx wasmtune train        # LoRA locally: Unsloth+CUDA, or MLX on Apple Silicon
    npx wasmtune eval         # gate: tuned must beat base, or this exits non-zero
    npx wasmtune convert      # GGUF + MLC (q4f16_1) + ONNX -> one manifest
    npx wasmtune eval-onnx    # scores the artifact that actually ships
    HF_TOKEN=hf_… npx wasmtune publish   # optional: Hub upload + manifest entry
    <script type="module">
      import { mountAssistant } from "wasmtune/chat";
      await mountAssistant({ target: "#chat", siteName: "Lumen" });
    </script>

    Two commands that are easy to skip and shouldn't be. eval-onnx runs after convert because eval scores fp16 weights while the browser loads a quantized graph — it fails the run if quantization cost more than 10%. publish is separate from convert on purpose: retraining is nondeterministic, so a build hook must not churn published weights. Run npx wasmtune train --dry-run to see the resolved config and which reward module will fire, before anything installs.

    The first visitor downloads the weights once and the browser caches them; every later visit is offline. This page calls exactly the two lines above — the panel above is a few more wasmtune/chat exports (detectHardware, fitReport, pickModel) used for display.

    Four methods, and which one to actually use

    This is the question that comes up first, and the honest answer is usually "SFT".

    what it optimizesuse it for
    sftnext-token match to a reference answerfacts — the validated path
    dpoa good answer ranks above a bad oneremoving one specific bad behaviour
    orpoDPO with no reference modelshaping when memory is tight (Macs)
    grpoRL against a reward functionoptimizing a measurable property

    What the default GRPO reward does, and what it can't do

    It counts how many of the reference answer's distinctive terms survive into the completion, and halves the score past 3× the reference length. Same signal wasmtune eval measures, so improvements show up in eval rather than a private metric.

    Two limits worth stating. It rewards the right words appearing, not the right value being produced — keys, config and credentials all appear in both ~/.config/lumen/credentials.json and ~/.config/lumen/API_KEYS.env, so those two score identically. And it cannot recover a fact the corpus never contained. If your facts collide like that, write your own reward comparing against the reference string.

    The default used to be min(len(c) / 500, 1.0) — it rewarded length, so the optimal strategy was to pad. It also turned out grpo.rewardFile was dead entirely: the template shipped ./rewards.mjs, the Python trainer loads with importlib (which cannot import .mjs), and nothing passed the path through — so every GRPO run silently used the length reward. It points at a .py module now.

    // rewards.py — export reward(prompts, completions, reference=None) -> list[float]
    def reward(prompts, completions, reference=None, **kw):
        refs = reference if isinstance(reference, (list, tuple)) else [reference] * len(completions)
        return [1.0 if c.strip() == (refs[i] or "").strip() else 0.0
                for i, c in enumerate(completions)]
    { "method": "grpo", "grpo": { "rewardFile": "./rewards.py", "numGenerations": 4 } }

    What actually decides whether this works

    Not the method. The corpus.

    Knowledge capacity scaling laws (Allen-Zhu & Li, arXiv:2404.05405) put storage at roughly 2 bits per parameter — so a 0.5B model holds on the order of 125 MB of facts. The corpus behind this demo is about 5 KB. The model has room for something like 25,000× more than it was given. What it did not get is exposure.

    evidenceverdict here
    Repetition / coveragerecall rises with how often a fact is seenthe real fix — more passages per fact, or --synth paraphrases
    Full FT instead of LoRALoRA Learns Less and Forgets Less (arXiv:2405.09673): low-rank LoRA substantially underperforms full FT on the target domainbuys something, costs the consumer-GPU premise
    Mixing known with new factsarXiv:2502.14502: a mixture beats packing new facts alone into the adapterwhat a bigger dataDir and --synth give you
    RL with a factual rewardMedFact-R1 (arXiv:2509.15154): up to +22.5pp absolute factual accuracyreshapes what the model emits; cannot add knowledge
    Knowledge editingROME / MEMIT — effective at LLM scale, brittle at 0.5Bout of scope

    Which is why the demo answers the way it does. Six pages, 51 training pairs, ~150 gradient steps: the model picks up the vocabulary without anchoring the values, and two facts that were literally in the training data did not survive. On this corpus, eval-onnx reports a small, honest delta rather than a story — more epochs on 51 pairs overfit and make it worse. Put more content in dataDir, especially more passages covering the same facts, and read untraced in eval.report.json to see which facts are confabulated rather than learned.

    Ask it, then judge for yourself

    These are the published model's actual answers, with the decoding settings this page uses:

    Q: How do rate limits work in Lumen?
    A: "\# is the rate limit in Lumen. It works as follows: If you are using a
        10 GB SSD and pages in 1-4 sentences..."          corpus facts: NONE
    
    Q: Where are API keys stored?
    A: "<API keys are stored in the database..."              corpus facts: NONE

    The corpus says per project, per minute; burst is limit × 1.5; 429 with Retry-After, and that keys go to ~/.config/lumen/credentials.json. Across four questions of this shape, 0 of 4 answers carried a single real corpus term. That is what 51 pairs does on a 0.5B model — the vocabulary lands, the values do not. It is not a bug in the pipeline, and the pretrained tiers above it are the honest comparison: those have never seen this site and will invent plausible answers about it, which is at least marked as inventing.

    wasmtune vs. a RAG / vector-DB setup

    Both answer from your content. Only one of them needs a running server to do it.

    wasmtunehosted RAG / vector DB
    Inference runsin the visitor's tab, over WebGPUon your GPU box or vendor's cluster
    Server bill$0 — weights are static files on a CDNper query, forever, plus a always-on indexer
    Needs a backendno. any static host: Pages, Netlify, S3yes: a running service with uptime and scaling
    Network at answer timezero — works in airplane modeone round trip per question, minimum
    User contentnever leaves the deviceshipped to a third party on every question
    Per-visitor tieringautomatic from measured hardwareone server config for everyone
    Fresh contentre-crawl + retrain in CIre-embed and re-index
    Ceiling0.1B–4B models: short, grounded, honest answersunbounded context, citations, arbitrary documents

    Where RAG still wins: your corpus is large or changes hourly, you need verbatim quotes with citations, or answers must span dozens of documents. wasmtune is for the other half of the problem — a small, stable corpus and a question that a 1B model can answer without inventing anything.