tinychaseAll kiosks

LAB EXPERIMENT: an analysis from the lab, not a tinychase data product. The findings here are the experiment's own, and where a model is used the output is the model's, not a measurement.

Does the cyclone transformer give the same forecast in a browser as in Python?

Lab · the cyclone transformer in the browser, reproduced

Does the cyclone transformer give the same forecast in a browser as in Python?

A reproduction in the analysis request format (docs/analysis-request-format.md, v0.1), answering lab/wind-transformer.md and the live forecaster lab/live.py. Model weights of 2026-09-15 (tf2_weights.npz, sha256 dfcc8ce5f9cc…), tracks pulled 2026-09-23 06:35 UTC. Run 2026-09-23. Model output throughout: nothing here is a measurement.

Verdict: reproduced. The v2 transformer, exported from tinygrad to WebGPU and run in Chrome, gives the same 184 forecasts for one storm as the Python model on the CPU: expected wind within 0.00003 kt, every percentile bin identical, rapid-intensification probability within 0.0000002. The page reports whole knots.

1. Question

notererun
questioncan a two-layer causal transformer read a track and forecast windcan the same trained model, moved to the browser, give the same numbers
whya sequence model with no hand-built featurestinychase does the work in the browser; the model must not change on the way there
yes meansthe track carries a forecastable signalthe export path is trustworthy and a model page can be built on it
no meansthe hand-built trends were doing the workfall back to ONNX Runtime Web or keep the model server-side

2. Data

stepnote (live.py, replay mode)rerun
sourcetc dossiers through get_slotsthe same, cached by the daily loop in data/tc_fixes.parquet (723,515 fixes, 13,553 storms)
stormthe 12 most recent storms with 12 or more synoptic fixesone of them: NARELLE, 2026076S12157, South Pacific basin
fixes for the storm in the dossier91
fixes at synoptic hours (00, 06, 12, 18 UTC, minute 0), one per time46
of those, with a wind value (a forecast is issued from each)46 of 46
6-hourly gridfirst to last fix2026-03-17 00Z to 2026-03-28 06Z, 46 steps, no gaps
windows (one per step, left-padded to 16)4646
forecasts (4 horizons per window)184184

The 09-15 log holds all 46 issues for this storm. Against that log, today's CPU rerun differs on 134 of 184 expected winds by at most 0.000013 kt, which is float32 summation order between tinygrad kernels; the tracks are unchanged.

3. Label

No label: this is inference. The rerun compares outputs, not scores. The quantities compared are the ones the live page shows: expected wind per horizon, the 10th, 50th and 90th percentile bins, the modal bin, and the probability of a 30 kt rise in 24 hours.

4. Features (the tokeniser)

notererun
tokenisertokens2.py rules: wind in 5 kt bins (38 classes, missing 38, pad 39), motion 7x7 classes of lat and lon step (missing 49, pad 50), latitude band, month, basin, pressure in 10 mb bins from 870 (16 classes, missing 16, pad 17)tfmodel.tokenise_track, the same code, run once in Python; the browser received the resulting int32 windows [16, 6] unchanged
window16 steps, stride 4 in training, a window ending at every step for inferenceone window per step, 46 windows

The model check tokenised in Python on both sides. The browser port of the tokeniser (tinychase src/lib/tf2.ts, with numpy's half-to-even rounding and np.digitize written out) was then checked on the same 91 fixes: 46 of 46 windows identical to Python's, token for token, and the same 46-step grid.

5. Split

None. Inference on one storm from 2026, inside the note's test years (2020 to 2026) and never in training (1980 to 2014).

6. Method

notererun
modeltinygrad, d 64, 4 heads, 2 blocks, 4 horizon heads of 38 bins, 120,664 parameters in 35 tensorsthe same weights, exported
exportlab/export_tf2_webgpu.py: tinygrad's extra/export_model.py (checkout 2148b59ef), vendored as lab/webgpu_export.py and adapted to tinygrad 0.14.0; WGSL rendered through the NULL device (DEV=NULL:WGSL), no GPU library needed
bundletf2.js 69,144 bytes: 23 distinct kernels, 33 kernel calls, 50 buffers (35 weights, 13 scratch, one input of 384 bytes, one output of 608 bytes); tf2.safetensors 485,456 bytes
runtimetinygrad on CPU (and on the NV backend for a second baseline)Chrome 153.0.8010.36 headless, WebGPU, adapter google/swiftshader (software; headless Chrome does not expose the RTX 3070s), device requested with the adapter's limits
per windowwrite 96 int32, 33 compute passes, read 152 float32; softmax, percentiles and the RI sum done in Python on the returned logits with tfmodel.softmax and tfmodel.summarise
codelab/live.py, lab/tfmodel.pylab/export_tf2_webgpu.py, lab/tf2_webgpu_expected.py (the windows and CPU logits), the harness page and comparison in the session scratch, to move into tinychase with the page

Three things were wrong before it matched, all in the export, none in the model:

  1. the exporter walked the captured program by topological sort, which de-duplicates identical calls. The second block's LayerNorm mean and variance kernels are the same program on the same scratch buffers as the first block's, so they were one UOp and ran once. Embeddings and block 0 matched to 0.0000011; block 1 was off by 4.07. The fix walks the call list in execution order, as the runtime does (9 kernels became 23, 23 calls became 33);
  2. kernel names are shape signatures and collided (r_16_16_4 was two different kernels); the later one overwrote the earlier in the JS. Kernels are now keyed by source;
  3. the embedding kernel binds 9 storage buffers and WebGPU's default limit is 8. The page requests the adapter's limits (SwiftShader offers 10).

7. Results

quantitycomparison over 46 windowsmax abs difference
logits, 4 x 38 per windowbrowser vs CPU0.0000038 (logit range -10.1 to 7.4)
probabilities after softmaxbrowser vs CPU0.00000072
expected wind, kt, 184 forecastsbrowser vs CPU0.000028
p10, p50, p90 and modal bins, 736 valuesbrowser vs CPU0 differ
P(rapid intensification), 46 valuesbrowser vs CPU0.00000011
second storm GEZANI 2026039S18057 (South Indian), 81 fixes, 41 windows, 164 forecasts: logitsbrowser vs CPU0.0000038
GEZANI expected wind, ktbrowser vs CPU0.000016
GEZANI p10, p50, p90 and modal bins, 656 valuesbrowser vs CPU0 differ
GEZANI P(rapid intensification), 41 valuesbrowser vs CPU0.00000005
expected wind, ktCPU today vs 09-15 log0.000013
expected wind, ktNV backend today vs 09-15 log0.000016

Two forecasts, as the page prints them (whole knots, then the 10th to 90th percentile bins):

issuenow6 h12 h24 h48 hRI
2026-03-17 00Z, Python39 kt43 (40 to 50)45 (35 to 55)50 (30 to 65)58 (25 to 95)8%
2026-03-17 00Z, browser39 kt43 (40 to 50)45 (35 to 55)50 (30 to 65)58 (25 to 95)8%
2026-03-28 06Z, Python29 kt42 (25 to 55)40 (30 to 55)38 (30 to 50)37 (25 to 50)2%
2026-03-28 06Z, browser29 kt42 (25 to 55)40 (30 to 55)38 (30 to 50)37 (25 to 50)2%

Wall time in the browser on the software adapter: 415 ms to compile 23 pipelines and load 485 KB of weights, 794 ms for 46 windows run one at a time (17 ms each). A hardware adapter will be faster; a batch-of-N export would be faster still and is not needed for one storm.

8. Caveats and next

Where the note was silent

Worth a second look

Environment

tinygrad 0.14.0 in the kiosks uv environment (Python 3.14); export via DEV=NULL:WGSL; Google Chrome 153.0.8010.36 headless via puppeteer-core, WebGPU on google/swiftshader (Vulkan on the NVIDIA cards is not reachable headless); Bun 1.3.14 for the page server and driver; CPU baseline on tinygrad's CPU backend, second baseline on NV. About three hours from first export to match, two of them on the three defects above.