a tiny pet with a GPT-2 brain, living in your browser
tokenling docs gotchas.md
16 kB
Markdown
at main

Gotchas #

Each of these cost real time while building or maintaining tokenling. Format: what you see, why, and what to do.

Model and prompting #

Every line starts with "ick", "icky", or other odd repeated words. The prompt ended with Name: (trailing space). GPT-2 has " word" tokens with the space attached, so a bare space token derails sampling. End prompts with Name:. Applies to the app (petLine), eval_pet.py and any new tool.

Tags come out mangled: [want_play], [clock speed], [got]. repetition_penalty in transformers.js penalizes tokens already in the prompt, which includes [, ], : and every tag name. Do not set it. The first browser tests with the trained model had it at 1.08 and tags were garbage; removing it fixed them.

[look][gift:flower] runs two tools together, or the lookup never stops. GPT-2 has a single ][ token, so the stop regex that expects a clean ] end never fires. The stopper accepts an optional trailing [ and the app trims it.

A format change that "looks harmless" breaks the pet. The model learned the exact header, word lists and stat thresholds. See AGENTS.md.

Lines pass eval_pet.py but say nonsense. The eval checks syntax, not meaning. Read transcripts. Known weak spots: irrelevant recall keywords, emotes that do not fit the question, diaries that embellish the day log.

The pet answers an offer in its own separate line, or answers when nothing was offered. Teachers like a bare Mochi: [yes] followed by a second line, and sprinkle [yes] into ordinary replies. The prompt asks for words in the answering line and the validator rejects answers without an open offer (the most common v2 rejection).

Teachers write stage directions (*purrs*). check_chat strips short *…* asides from pet lines, since emotes carry that meaning, and rejects anything left; the app also strips * from owner input.

The base model can say explicit things. GPT-2 was trained on the open web. NOT_CUTE in web/app/text.ts filters and retries; the fine-tune makes it rare, not impossible. Keep the filter.

Chat history seems ignored. The context is 1024 tokens and the header takes about 150. fit() drops the oldest chat lines first. Durable facts belong in remembers: / today: or behind a lookup.

Data generation #

Braces in the teacher prompt. The static rules are inserted into a str.format template, so literal {...} must be doubled; gen_data.py does this with SPEC_STATIC.replace("{", "{{"). Edit the template, not the escaped copy.

Teacher output slips in invented values ([emote:curious], [want:fun], [yawn]). Add an alias in EMOTE_ALIAS only when the meaning is clear; otherwise let validation reject it. About 22% of samples are rejected.

Topics over-represented in the data come from the state sampler, not the teacher. Poop appears in a third of rows through the today: line and [look] results. See training.md.

[recall] must see memories saved earlier in the same chat. Through v2, check_chat computed recall results from the sample's starting memories only, so "remember that X … what do you remember about X?" was rewritten to {nothing} and taught the pet that saving does not work. The validator now keeps a live memory list, as the app does with S.memories.

Do not pass current-format data to mix_data.py as OLD. It drops OLD rows that use [walk] (v1 had none), so newer rows given as OLD vanish. Pass them as NEW (or OLD as /dev/null): NEW rows are only re-checked.

An offer answered "yes" with no [yes]. v4 sometimes answered food with yes!! [emote:bounce] [diary:ate a cookie], so the food vanished uneaten. The eval (one offer after a short chat) showed 80/80 answered and missed it. Teachers put [yes]/[no] first or right after the words in 99% of answers, so once sampling put an emote first (rare), the model took the answer as skipped: forced to start yes yes!! [emote:bounce], v4 still answered only 65% of the time. Training now moves a share of answers to the end of the line (--late-answer), and eval_pet.py measures this case. Look at where in the line a required tag sits, not only whether it is there.

Stale rows outlive the app behavior they were written for. While chasing the bug above, 676 v4 rows turned out to put retired care events in the owner's mouth (Bea: * Bea fed Byte a rice ball *, then the pet eating with no answer); the old mix_data.py only checked lines starting with * . Dropping them alone did not fix the offers, but train.py now re-checks every row with gen_data.recheck and refuses stale data, so a new rule catches old rows at the next run.

The held-out set is defined by a random seed. train.py shuffles rows with random.seed(0) and takes the first 120; quant_eval.py repeats that shuffle. Anything that consumes random before the shuffle, or a changed row count, changes the split.

A valid transcript can still have a corrupt loss mask. Checking a canonicalized body without checking the stored text and spans accepts empty, shifted or owner/header/result-trained masks. recheck now compares canonical text and semantic span coverage and refuses these rows; explicit migration rebuilds the masks instead. Training-only donor history is added afterward and stays untrained.

Sleep migration drops a valid dream after a message or pat. A disturbance can fail to wake the pet. Preserve already-valid no-wake transcripts, and in legacy mixed logs insert a wake only where an awake reply needs it. Forcing a wake at every disturbance turns later dream tags into invalid awake replies.

GPUs #

A shared model server is too small for bulk generation (a handful of concurrent requests). Start dedicated batch servers with train/teachers.sh start, and give the GPUs back afterwards (deployment.md).

pkill -f / pgrep -f can match your own shell. The command text is in the shell's command line, so the pattern finds it. teachers.sh stop kills by process group instead; do the same for anything else, or anchor patterns like [v]llm serve.

A teacher server dies at startup with a FlashInfer version error. A vLLM install whose flashinfer and flashinfer-cubin versions disagree fails in attention-backend selection. Fix the install, or pass VLLM_ARGS="--attention-backend FLASH_ATTN" (and VLLM_USE_FLASHINFER_SAMPLER=0 in the environment). teachers.sh wait reports a server that exited, with the log tail, instead of waiting for it.

Check the GPUs before sharing one. Training at batch 16 and 1024 tokens peaks around 15 GB per 24 GB card, and the 50K-vocabulary logits make the longest batches allocator-tight. Read nvidia-smi before starting a second job on a card.

Export and runtime #

Linux Firefox says "Error in input stream" during the brain download. Reproduced with a throttled model transfer: the service worker's cache-first proxy cut off the stream after about 30 seconds. .onnx fetches must bypass respondWith; transformers.js already owns their offline cache. Returning fetch(req) inside respondWith still proxies the body. Test a transfer longer than 30 seconds, not just a fast local download (testing.md). An interrupted response can also leave a padded invalid ONNX in the model cache, followed by protobuf parsing failed. The loader evicts that brain's ONNX on either error before offering retry; clearing pet saves is unnecessary.

Firefox leaves the egg warming without starting a download. Waiting for navigator.storage.persist() can wait on an unanswered browser permission prompt. Request persistence without awaiting it; the download works without the grant. A persistent test profile alone does not grant that permission.

ONNX export fails or the fp32 file is too big. Use the separate export venv (optimum-onnx needs transformers 4.57, training uses 5.x) and install accelerate, which de-duplicates the tied embeddings.

The 4-bit model is several times slower than fp32 in the browser. ONNX Runtime's MatMulNBits (its 4-bit matmul, which MatMulNBitsQuantizer emits and its QDQ fusion creates) has no fast WebAssembly kernel: 7 tokens/s against 34 for fp32 on the same machine. Store 4-bit weights behind DequantizeLinear and load with session.disable_quant_qdq=1 so they fold to fp32 (quantize.py, brain.ts).

The model never finishes loading (or is very slow) in a new runtime or test script. It was loaded without session.disable_quant_qdq=1.

Page memory is gigabytes after loading a small model. ONNX Runtime keeps every intermediate of a constant-folded subgraph until folding ends, and the WebAssembly heap never shrinks. Unpacking nibbles with BitShift/Cast/Mul chains peaked at 3.1 GB for GPT-2 small; one DequantizeLinear per weight, fp32 scales, no prepacking and a single shared embedding/head table brought it to 0.8 GB. Measure with bench.html (window.mem()).

GPT-2's LM head will not quantize to 4 bits. Tied embedding at 4 bits: loss 4.23 → 6.98 on the base model. Keep it int8 per token row.

Int8 activation quantization wrecks GPT-2. quantize_dynamic on the head and MatMulNBits with accuracy_level=4 quantize activations, and GPT-2 has large activation outliers. Quantize weights only.

ORT aborts with _M_is_engaged() at session creation. A per-axis DequantizeLinear with QDQ fusion enabled. Set disable_quant_qdq.

Wrong or missing .wasm. The runtime directory must hold the ORT build that the transformers.js bundle was compiled against. vendor.sh pins and checks it.

import … from "onnxruntime-web" fails in the browser. You used transformers.web.js; use transformers.min.js.

ERR_BLOCKED_BY_RESPONSE for ort-wasm-simd-threaded.asyncify.mjs. Seen once in a profile that had cached the runtime before COOP/COEP were enabled. Clearing the HTTP cache fixed it. Not root-caused.

Third-party resources vanish on the page. COEP: require-corp blocks anything cross-origin without CORP headers. Host it same-origin (the page's font is vendored for this reason).

Changing a published model or runtime file has no effect. They are served immutable for a year. Publish a new directory name.

Browser testing #

The model downloads every reload in a Playwright session. In-memory profiles reject Cache Storage writes. Use --profile=DIR and a named session.

The chat keeps changing under a test. Idle turns run every 8–20 s once the brain is ready. Read state().chat right after the turn you caused, or turn the "lively" setting off.

The egg never hatches in a test. Hatching waits for the brain; a test must wait for brain.state === "ready" after "get an egg".

You cannot catch an animation with separate CLI calls. Trigger and screenshot inside a single run-code.

Stats jump twice when returning to a tab. Catch-up replay and the live animation-frame tick have separate clocks. After replaying an absence, advance the live baseline too; otherwise its next frame applies that same elapsed time again and can bypass the seven-day cap. Short gaps not replayed by catch-up must still reach the live tick once.

A storage change made in a test vanishes. The running app saves the whole pet every second, so a localStorage.setItem on the pet page is overwritten before a reload reads it. Write from another same-origin page that does not run the app (for example the font license under assets/), then navigate back.

playwright-cli eval reports "Unexpected token ';'". It evaluates an expression. Wrap statements in a function: eval "() => { …; return 1 }".

Alerts never show in a headless test. Headless Chromium reports every page as visible, and the worker never alerts while a pet page is visible. Navigate the tab to another page in the worker's scope first.

A build env var silently pointed somewhere else. test/dev-site.sh takes RUNTIME as a directory, so site/build.sh names its URL overrides RUNTIME_URL and MODELS_URL; reusing one name sent the browser to a disk path.

Chat looks empty but the model "answered". Lines of only tags render as chips or an ellipsis. Inspect window.tokenling.state().chat for the raw text.

Deployment #

The model keeps loading the old file. Models and the runtime are served immutable; a changed file under the same URL is ignored by every browser that has it. Publish a new directory name.

A rejected build deletes the previous page, or a failed publish cannot be retried. Do not replace live output before staging and validating a build. Likewise, finish the runtime copy before committing an immutable model id; otherwise a runtime failure strands the model and the existing-id guard blocks retry. The builder stages first, and the publisher completes both partials before publishing the runtime and then the model.

Static files unreadable by the web server. train/publish.sh writes files 0644 and directories 0755 by default. If your server runs as a different user in a shared group, set FILE_MODE and DIR_MODE (for example 0640 and 2750 under a setgid parent).

Stage catchphrases can drown out needs #

The v5 teacher asked teens to say "ugh" and "literally". In 3,037 teen chats, 2,992 used one of them; "literally starving" became filler even with a stuffed tummy. v6 replaces those chats and teen diaries with a varied, sincere voice and checks positive hunger claims against full/stuffed needs. Offer tags alone do not prove the surrounding words agree with the body.

Checking bracket pairs misses broken diary stickers #

The older diary validator accepted stray [ and split doodle: fragments because it only examined complete tag pairs. Strip valid doodle tags, then reject remaining brackets or doodle:. Tell the teacher to sign with the actual pet name; a literal "- PET" example was copied into five old rows.

Sleep teachers should not choose body events #

The first v6 sleep probe accepted 11/40 chats: the teacher skipped events or invented wakes. The generator now samples the wake rolls and supplies the exact events, asking for a JSON array of pet lines only. It inserts the wake after an accepted sleeping offer, then validates the assembled transcript. The next probe accepted 25/40. This keeps app-owned transitions out of the teacher's creative output without choosing dreams or offer answers for it.

When moving a sampled sleep scene out of the morning natural-wake window, update its weather along with its hour. An early v6 batch moved the hour to night but left daytime weather in 113 rows; final rechecking drops them.

Diary logs need explicit subjects #

A v6 review found diaries reading took medicine as the owner taking it, or gave OWNER a heart as the owner giving the pet a heart. The first teen diary batch was replaced after review. The teacher now gets explicit log semantics, keeps remembered facts separate from today's events, and writes shorter entries instead of inventing activity to fill a long paragraph. These are semantic errors: bracket validation alone cannot catch them.

A cached reload says the pet is in another tab #

The old document's Web Lock can briefly outlive navigation. A single immediate ifAvailable request may fail even though the old tab is going away. Release the held promise on pagehide and retry acquisition briefly without stealing (web/app/lifecycle.ts). In browser tests, wait for the closing tab's lock to leave navigator.locks.query() before asserting ownership in another tab.