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
| note | rerun | |
|---|---|---|
| question | can a two-layer causal transformer read a track and forecast wind | can the same trained model, moved to the browser, give the same numbers |
| why | a sequence model with no hand-built features | tinychase does the work in the browser; the model must not change on the way there |
| yes means | the track carries a forecastable signal | the export path is trustworthy and a model page can be built on it |
| no means | the hand-built trends were doing the work | fall back to ONNX Runtime Web or keep the model server-side |
2. Data
| step | note (live.py, replay mode) | rerun |
|---|---|---|
| source | tc dossiers through get_slots | the same, cached by the daily loop in data/tc_fixes.parquet (723,515 fixes, 13,553 storms) |
| storm | the 12 most recent storms with 12 or more synoptic fixes | one of them: NARELLE, 2026076S12157, South Pacific basin |
| fixes for the storm in the dossier | 91 | |
| fixes at synoptic hours (00, 06, 12, 18 UTC, minute 0), one per time | 46 | |
| of those, with a wind value (a forecast is issued from each) | 46 of 46 | |
| 6-hourly grid | first to last fix | 2026-03-17 00Z to 2026-03-28 06Z, 46 steps, no gaps |
| windows (one per step, left-padded to 16) | 46 | 46 |
| forecasts (4 horizons per window) | 184 | 184 |
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)
| note | rerun | |
|---|---|---|
| tokeniser | tokens2.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 |
| window | 16 steps, stride 4 in training, a window ending at every step for inference | one 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
| note | rerun | |
|---|---|---|
| model | tinygrad, d 64, 4 heads, 2 blocks, 4 horizon heads of 38 bins, 120,664 parameters in 35 tensors | the same weights, exported |
| export | lab/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 | |
| bundle | tf2.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 | |
| runtime | tinygrad 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 window | write 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 | |
| code | lab/live.py, lab/tfmodel.py | lab/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:
- 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);
- kernel names are shape signatures and collided (
r_16_16_4was two different kernels); the later one overwrote the earlier in the JS. Kernels are now keyed by source; - 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
| quantity | comparison over 46 windows | max abs difference |
|---|---|---|
| logits, 4 x 38 per window | browser vs CPU | 0.0000038 (logit range -10.1 to 7.4) |
| probabilities after softmax | browser vs CPU | 0.00000072 |
| expected wind, kt, 184 forecasts | browser vs CPU | 0.000028 |
| p10, p50, p90 and modal bins, 736 values | browser vs CPU | 0 differ |
| P(rapid intensification), 46 values | browser vs CPU | 0.00000011 |
second storm GEZANI 2026039S18057 (South Indian), 81 fixes, 41 windows, 164 forecasts: logits | browser vs CPU | 0.0000038 |
| GEZANI expected wind, kt | browser vs CPU | 0.000016 |
| GEZANI p10, p50, p90 and modal bins, 656 values | browser vs CPU | 0 differ |
| GEZANI P(rapid intensification), 41 values | browser vs CPU | 0.00000005 |
| expected wind, kt | CPU today vs 09-15 log | 0.000013 |
| expected wind, kt | NV backend today vs 09-15 log | 0.000016 |
Two forecasts, as the page prints them (whole knots, then the 10th to 90th percentile bins):
| issue | now | 6 h | 12 h | 24 h | 48 h | RI |
|---|---|---|---|---|---|---|
| 2026-03-17 00Z, Python | 39 kt | 43 (40 to 50) | 45 (35 to 55) | 50 (30 to 65) | 58 (25 to 95) | 8% |
| 2026-03-17 00Z, browser | 39 kt | 43 (40 to 50) | 45 (35 to 55) | 50 (30 to 65) | 58 (25 to 95) | 8% |
| 2026-03-28 06Z, Python | 29 kt | 42 (25 to 55) | 40 (30 to 55) | 38 (30 to 50) | 37 (25 to 50) | 2% |
| 2026-03-28 06Z, browser | 29 kt | 42 (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
- The proof used the software adapter. The kernels are plain WGSL with no extension, so a hardware adapter runs the same code; the numbers may differ in the last float32 digit.
- The
tcsnapshot on the lake had no fixes (slot_id, name, start, end, lat, lon, n_obs). Resolved the same day, both ways at Mike's call: the nightly builder now writes a track file besidetc(latest.track.parquet, 723,515 fixes from 13,553 dossiers, 5.4 MB, one row group per year, described undertrackin tc's manifest; Narelle's 91 fixes identical to the kiosks' cache), andtinychase.comis in the API's allowed origins for the per-slot dossier. The model page reads the lake file. - The one check that would most change the conclusion: a second storm from another basin through the same harness. Done: GEZANI, South Indian basin, in the results table; the same agreement.
Where the note was silent
- Which device the daily run uses: the kiosks environment defaults to the NV backend on this box. The CPU and NV baselines agree with each other and with the log to 0.000016 kt, so it did not matter.
- The reported precision: the live page prints expected wind with
toFixed(0), bins as their 5 kt centres, RI as a whole percent. Taken as the bar; the rerun clears it by four orders.
Worth a second look
live.pyruns the model in batches of 128 windows; the export is a batch of one. Same numbers, so batching is not changing the arithmetic.- The exporter's toposort defect would affect any model that reuses a kernel on the same scratch buffers, which after tinygrad's memory planner is most models with repeated blocks. Worth an upstream issue.
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.