Quazim0t0 commited on
Commit
30bafb7
·
verified ·
1 Parent(s): 33bfa07

DaisyChain-Infer: project files + guide

Browse files

Layer-sharded peer-to-peer inference. Each device downloads only its own
layers from the Hub via safetensors byte ranges, so no device holds the
whole model -- this pools memory, which DaisyChain-Train explicitly cannot.

Loads any Llama-style or GPT-2-style .safetensors repo. Verified INT8 units,
WGSL kernels and their exact gates carried over from DaisyChain-Web unchanged.
47 checks: bit-exact split equivalence (both families, with mutation checks),
wire protocol refusals, ring routing, safetensors/dtype/BPE loader.

GUIDE.md ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ❄ DaisyChain-Infer — run one model across your devices
2
+
3
+ Part of **DaisyChain** → https://huggingface.co/DaisyChainAI
4
+
5
+ Point it at a **Hugging Face model**, open the page on two or more devices, and
6
+ each one takes a slice of the model's layers. A token is produced by passing
7
+ the hidden state around the ring, peer-to-peer over WebRTC. Every multiply runs
8
+ through the same **verified INT8 units** as the rest of DaisyChain — WebGPU
9
+ where available, the identical math on CPU where not.
10
+
11
+ **This runs locally.** It is a project you clone and start on your own
12
+ machines; there is no hosted deployment, and it is not a Space.
13
+
14
+ ```bash
15
+ npm install
16
+ npm start # http://localhost:8788
17
+ ```
18
+
19
+ Built from [DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train)
20
+ and [DaisyChain-Web](https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web) —
21
+ the verified units, the WGSL kernels and their exact gates, and the WebRTC mesh
22
+ are carried over unchanged.
23
+
24
+ ---
25
+
26
+ ## What is actually new here
27
+
28
+ DaisyChain-Train is explicit about its limit: it **pools compute, not memory**.
29
+ Every node holds a full replica, so a model bigger than one machine cannot be
30
+ trained, and chaining five laptops does not give you one big machine.
31
+
32
+ Inference is where that limit can be lifted, because a forward pass is a chain:
33
+ layer `l` needs layer `l-1`'s **output**, never its **weights**.
34
+
35
+ And because safetensors gives every tensor an exact byte range, **each device
36
+ downloads only its own layers, straight from the Hub**. No device — not even
37
+ the one driving the run — ever holds the whole model.
38
+
39
+ | | DaisyChain-Train | DaisyChain-Infer |
40
+ |---|---|---|
41
+ | What is split | the **batch** | the **model** |
42
+ | What crosses the wire | gradients (whole-model sized, every step) | hidden states (`T×hidden` floats, per hop) |
43
+ | Every device holds | the entire model | its own layers only |
44
+ | Pools | compute | **memory** |
45
+ | More devices means | more throughput | a **bigger model fits** |
46
+
47
+ Running SmolLM-135M across three devices, each one downloads and holds
48
+ 135–243 MB of a 513 MB model. The activation moving between them is tens of KB.
49
+ That asymmetry is why this works.
50
+
51
+ ## The ring
52
+
53
+ ```
54
+ ┌──────────────── hidden state (T×hidden f32) ─────────────┐
55
+ ▼ │
56
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
57
+ │ stage 0 │───────▶│ stage 1 │───────▶│ stage 2 │───────────────┘
58
+ │ HEAD │ │ layers │ │ layers │
59
+ │ embed │ │ 10–19 │ │ 20–29 │
60
+ │ layers │ └─────────┘ └─────────┘
61
+ │ 0–9 │
62
+ │ lm_head │◀── the returning state becomes the next token
63
+ └─────────┘
64
+ ```
65
+
66
+ It is a **ring, not a line**, and weight tying forces that. When `lm_head` is
67
+ tied to the embedding table — as it is in most small models — the largest
68
+ tensor is needed at *both* ends: to embed the prompt and to produce the logits.
69
+ Copying it to the last stage would hand back most of the memory just pooled, so
70
+ the last stage returns its hidden state to the head, which owns the embedding
71
+ **once** and does both ends.
72
+
73
+ A consequence worth stating plainly: a middle stage receives layer weights
74
+ only. It never gets the embedding table, so **it never sees the vocabulary** —
75
+ it passes floats it cannot interpret.
76
+
77
+ ## Models it can run
78
+
79
+ Any Hugging Face repo with `.safetensors` weights whose architecture is:
80
+
81
+ - **Llama-style** — Llama, Mistral, Qwen2/2.5, SmolLM, TinyLlama
82
+ (RMSNorm, RoPE, grouped-query attention, SwiGLU)
83
+ - **GPT-2-style** — LayerNorm with bias, learned positions, fused QKV, GELU
84
+
85
+ Verified working end to end: `HuggingFaceTB/SmolLM-135M`,
86
+ `openai-community/gpt2`, `Qwen/Qwen2.5-0.5B`.
87
+
88
+ Anything else is **refused by name**, not approximated. Treating an unknown
89
+ architecture as a known one produces fluent, confident, wrong output, which is
90
+ the failure this project spends its verification budget making impossible. The
91
+ same applies to tokenizers: byte-level BPE is implemented, and a
92
+ SentencePiece/Unigram repo is rejected rather than tokenized approximately.
93
+
94
+ Practical ceiling: weights are held as f32, so budget ~4 bytes per parameter
95
+ across the group, and remember the head also carries the embedding table.
96
+
97
+ ## The token
98
+
99
+ Gated or private models need a Hugging Face token. You are asked for one
100
+ **once**, when a request actually fails for want of it — not up front.
101
+
102
+ It is held in memory for that tab and nowhere else: **not** localStorage, not
103
+ sessionStorage, not a cookie, not the URL, never written to the log, and
104
+ **never sent to another device**. Each device is asked for its own, because
105
+ each device fetches its own layers. Reloading the tab forgets it, and there is
106
+ a *Forget token* button.
107
+
108
+ ## Verification
109
+
110
+ The trainer's stack carries over unchanged: exact init gates on every kernel on
111
+ every boot, the continuous random-cell audit at live shapes, and the
112
+ cross-device kernel probe.
113
+
114
+ But a pipeline moves the risk somewhere those instruments cannot reach:
115
+
116
+ > In data-parallel **training**, every peer computes the same thing, so a
117
+ > device with broken arithmetic shows up as a diverging replica. In a
118
+ > **pipeline**, each stage computes something *different* and nobody else
119
+ > repeats it. There is no replica to compare against. A wrong middle stage
120
+ > produces a fluent, confident, wrong answer, and no consistency check
121
+ > anywhere in the system would notice.
122
+
123
+ Four things close that:
124
+
125
+ 1. **The kernel probe**, which matters more here than in the trainer — the same
126
+ seeded GEMM on every device, so it stays comparable even when the real work
127
+ is not. A stage whose probe disagrees is flagged before it is given layers.
128
+ 2. **Activation integrity hashes** on every hop, plus the model fingerprint, so
129
+ a stage still holding a slice of a *different* model refuses the hop instead
130
+ of silently mixing two models.
131
+ 3. **Structural validation** of every assignment — a plan that does not cover
132
+ each layer exactly once is refused, because it would still generate fluent
133
+ text with a layer missing.
134
+ 4. **The differential check** (tick *Verify*). The driving device re-runs the
135
+ identical prompt with every layer locally and compares token ids. It is the
136
+ only instrument that can show a distributed answer is *right* rather than
137
+ merely self-consistent.
138
+
139
+ ```bash
140
+ npm test # pipeline equivalence + wire protocol + loader
141
+ ```
142
+
143
+ `test_pipeline.js` asserts that splitting changes **no bit**, for *both*
144
+ architecture families, across several uneven splits including a head that keeps
145
+ no layers. Two cases are mutation checks — a stage that silently drops a layer,
146
+ and stages applied out of order — both of which *must* fail the comparison.
147
+ Without those, a test where both sides call the same code proves nothing.
148
+
149
+ This was also confirmed against a real model: SmolLM-135M's 30 layers split
150
+ `[10,10,10]`, `[1,14,15]`, `[0,15,15]` and `[5,5,5,5,5,5]` all produced token
151
+ sequences identical to the single-device run.
152
+
153
+ `test_loader.js` builds safetensors files and reads them back, checks BF16/F16
154
+ widening is exact (including subnormals and signed zero), and round-trips the
155
+ BPE tokenizer. `test_wire.js` round-trips the protocol and refuses the
156
+ malformed messages that would otherwise produce plausible wrong answers — a
157
+ plan with a gap, a crafted repo id, a one-ulp flip deep in an activation, a
158
+ `-0` flipped to `+0`.
159
+
160
+ ### One bug, and what it cost to find
161
+
162
+ The first live two-device run stalled. The head is both stage 0 *and* the
163
+ ring's terminus, so an activation addressed to index 0 meant "start the lap"
164
+ outbound and "the lap is finished" inbound. The head read the return leg as its
165
+ own turn, re-ran its own layers, forwarded again, and the lap never closed.
166
+
167
+ Every number was correct. Every message round-tripped. **Neither test suite
168
+ could see it** — the pipeline test calls the stages in order itself, and the
169
+ codec test only checks bytes. The defect lived in the *route*, precisely the
170
+ category DaisyChain-Web's own self-corpus writeup identified as needing
171
+ different instruments rather than better oracles. The fix gave the return leg
172
+ its own address, and the routing decision moved out of the event handler into a
173
+ pure function so `walkLap` in `test_wire.js` can walk a lap and assert it
174
+ visits each stage once and terminates. That test fails against the old
175
+ behaviour; it was checked.
176
+
177
+ ## Honest limits
178
+
179
+ - **Latency, not bandwidth, is the cost.** Every token pays one round trip per
180
+ stage. More stages buy capacity, not speed — expect tokens/sec to *fall* as
181
+ you add devices.
182
+ - **No KV cache.** Every token re-runs the whole window, which is also what
183
+ keeps the split bit-comparable against an unsplit run. Context length is the
184
+ dominant per-token cost.
185
+ - **f32 in memory.** Weights are widened from F16/BF16 on load, so a stage
186
+ costs 4 bytes per parameter it holds.
187
+ - **The head is a single point of failure**, and a stage that drops stalls the
188
+ ring; press Generate again to re-plan around whoever is still connected.
189
+ - **No authentication of activations.** A malicious stage that runs correct
190
+ math but returns a crafted activation is not caught by any of this — the
191
+ verification proves the *computation* is right on every honest device. Peers
192
+ see each other's IPs, and the head sees your prompt. Run rings with people
193
+ you trust.
194
+
195
+ ---
196
+
197
+ **License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dean Byrne (Quazim0t0) / DaisyChainAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - distributed-inference
5
+ - pipeline-parallel
6
+ - webgpu
7
+ - webrtc
8
+ - int8
9
+ - old-hardware
10
+ ---
11
+
12
+ # ❄ DaisyChain-Infer — run one model across several devices
13
+
14
+ **Part of DaisyChain on 🤗 Hugging Face → https://huggingface.co/DaisyChainAI**
15
+ **Live Space:** https://huggingface.co/spaces/Quazim0t0/DaisyChain-Infer
16
+
17
+ ---
18
+
19
+ > **In plain terms:** point it at a Hugging Face model, open the page on two or
20
+ > more devices, and each one holds only a **slice of the model's layers**. A
21
+ > token is produced by passing the hidden state around the ring, peer-to-peer.
22
+ > The group can run a model that **no single device could hold**.
23
+ > Before you rely on it, read [Honest limits](#honest-limits).
24
+
25
+ This repo is the **project and the guide**. Clone it and run it on your own
26
+ machines:
27
+
28
+ ```bash
29
+ npm install
30
+ npm start # http://localhost:8788
31
+ npm test # 47 checks: pipeline equivalence, wire protocol, loader
32
+ ```
33
+
34
+ Full walkthrough: **[docs/GETTING_STARTED.md](docs/GETTING_STARTED.md)** ·
35
+ Internals: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** ·
36
+ The guide as one page: **[GUIDE.md](GUIDE.md)**
37
+
38
+ ---
39
+
40
+ ## What this adds to DaisyChain
41
+
42
+ DaisyChain-Train is explicit about its limit: it **pools compute, not memory**.
43
+ Every node holds a full replica, so a model bigger than one machine cannot be
44
+ trained, and chaining five laptops does not give you one big machine.
45
+
46
+ Inference is where that limit can be lifted, because a forward pass is a chain:
47
+ layer `l` needs layer `l-1`'s **output**, never its **weights**. So the layers
48
+ live on different machines and the activation travels instead.
49
+
50
+ And because safetensors gives every tensor an exact byte range, **each device
51
+ fetches only its own layers straight from the Hub**. No device — not even the
52
+ one driving the run — ever holds the whole model. That is what makes the pooling
53
+ real rather than a redistribution of something one machine already had to load.
54
+
55
+ | | DaisyChain-Train | DaisyChain-Infer |
56
+ |---|---|---|
57
+ | What is split | the **batch** | the **model** |
58
+ | What crosses the wire | gradients (whole-model sized, every step) | hidden states (`T×hidden` floats, per hop) |
59
+ | Every device holds | the entire model | its own layers only |
60
+ | Pools | compute | **memory** |
61
+ | More devices means | more throughput | a **bigger model fits** |
62
+
63
+ Running SmolLM-135M across three devices, each downloads and holds 135–243 MB
64
+ of a 513 MB model, and the activation moving between them is tens of KB. That
65
+ asymmetry is why this works.
66
+
67
+ ## The ring
68
+
69
+ ```
70
+ ┌──────────────── hidden state (T×hidden f32) ─────────────┐
71
+ ▼ │
72
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
73
+ │ stage 0 │───────▶│ stage 1 │───────▶│ stage 2 │───────────────┘
74
+ │ HEAD │ │ layers │ │ layers │
75
+ │ embed │ │ 10–19 │ │ 20–29 │
76
+ │ layers │ └─────────┘ └─────────┘
77
+ │ 0–9 │
78
+ │ lm_head │◀── the returning state becomes the next token
79
+ └─────────┘
80
+ ```
81
+
82
+ It is a **ring, not a line**, and weight tying forces that. When `lm_head` is
83
+ tied to the embedding table — as it is in most small models — the largest tensor
84
+ is needed at *both* ends: to embed the prompt and to produce the logits. Copying
85
+ it to the last stage would hand back most of the memory just pooled, so the last
86
+ stage returns its hidden state to the head, which owns the embedding **once**.
87
+
88
+ A consequence worth stating plainly: a middle stage receives layer weights only.
89
+ It never gets the embedding table, so **it never sees the vocabulary** — it
90
+ passes floats it cannot interpret.
91
+
92
+ ## Models it can run
93
+
94
+ Any Hugging Face repo with `.safetensors` weights whose architecture is:
95
+
96
+ - **Llama-style** — Llama, Mistral, Qwen2/2.5, SmolLM, TinyLlama
97
+ (RMSNorm, RoPE, grouped-query attention, SwiGLU)
98
+ - **GPT-2-style** — LayerNorm with bias, learned positions, fused QKV, GELU
99
+
100
+ Verified end to end: `HuggingFaceTB/SmolLM-135M`, `openai-community/gpt2`,
101
+ `Qwen/Qwen2.5-0.5B`.
102
+
103
+ Anything else is **refused by name**, not approximated. Treating an unknown
104
+ architecture as a known one produces fluent, confident, wrong output — the
105
+ failure this project spends its verification budget making impossible. The same
106
+ applies to tokenizers: byte-level BPE is implemented, and a SentencePiece or
107
+ WordPiece repo is rejected rather than tokenized approximately.
108
+
109
+ Two details that are silent when wrong, and so are decided by evidence rather
110
+ than assumption:
111
+
112
+ - **Weight layout.** `torch.nn.Linear` stores `(out, in)`; GPT-2's `Conv1D`
113
+ stores `(in, out)`, which is already the `k×n` the GEMM wants. A wrong
114
+ transpose does not throw.
115
+ - **Bias presence.** Qwen2.5 ships q/k/v biases and SmolLM does not, and neither
116
+ says so in `config.json`. Bias is decided by what the weight file contains.
117
+
118
+ ## Credentials
119
+
120
+ Public models need nothing. For gated or private ones, the **local** build asks
121
+ for a read token once, when a request actually fails for want of it — held in
122
+ memory for that tab only, never written to storage, never logged, never put in a
123
+ URL, and **never sent to another device** (each device authenticates itself,
124
+ because each fetches its own layers).
125
+
126
+ The hosted Space instead offers **Sign in with Hugging Face** and has no
127
+ paste-a-token box at all, because typing a personal access token into a page you
128
+ do not control is a bad habit even when the code is honest.
129
+
130
+ ## Verification
131
+
132
+ The trainer's stack carries over unchanged: exact init gates on every kernel on
133
+ every boot, the continuous random-cell audit at live shapes, and the cross-device
134
+ kernel probe.
135
+
136
+ But a pipeline moves the risk somewhere those instruments cannot reach:
137
+
138
+ > In data-parallel **training**, every peer computes the same thing, so a device
139
+ > with broken arithmetic shows up as a diverging replica. In a **pipeline**, each
140
+ > stage computes something *different* and nobody else repeats it. There is no
141
+ > replica to compare against. A wrong middle stage produces a fluent, confident,
142
+ > wrong answer, and no consistency check anywhere in the system would notice.
143
+
144
+ Four things close that:
145
+
146
+ 1. **The kernel probe**, which matters more here than in the trainer — the same
147
+ seeded GEMM on every device, so it stays comparable even when the real work is
148
+ not. A stage whose probe disagrees is flagged before it is given layers.
149
+ 2. **Activation integrity hashes** on every hop, plus the model fingerprint, so a
150
+ stage still holding a slice of a *different* model refuses the hop instead of
151
+ silently mixing two models.
152
+ 3. **Structural validation** of every assignment — a plan that does not cover
153
+ each layer exactly once is refused, because it would still generate fluent
154
+ text with a layer missing.
155
+ 4. **The differential check** — the head re-running the identical prompt with
156
+ every layer locally and comparing token ids. The only instrument that can show
157
+ a distributed answer is *right* rather than merely self-consistent.
158
+
159
+ `test_pipeline.js` asserts that splitting changes **no bit**, for both
160
+ architecture families, across several uneven splits including a head that keeps
161
+ no layers. Two cases are mutation checks — a stage that silently drops a layer,
162
+ and stages applied out of order — both of which *must* fail the comparison.
163
+ Without those, a test where both sides call the same code proves nothing.
164
+
165
+ Confirmed on a real model too: SmolLM-135M's 30 layers split `[10,10,10]`,
166
+ `[1,14,15]`, `[0,15,15]` and `[5,5,5,5,5,5]` all produced token sequences
167
+ identical to the single-device run.
168
+
169
+ `test_loader.js` builds safetensors files and reads them back, checks BF16/F16
170
+ widening is exact (including subnormals and signed zero), and round-trips the BPE
171
+ tokenizer. `test_wire.js` round-trips the protocol and refuses the malformed
172
+ messages that would otherwise produce plausible wrong answers — a plan with a
173
+ gap, a crafted repo id, a one-ulp flip deep in an activation, a `-0` flipped to
174
+ `+0`.
175
+
176
+ ### One bug, and what it cost to find
177
+
178
+ The first live two-device run stalled. The head is both stage 0 *and* the ring's
179
+ terminus, so an activation addressed to index 0 meant "start the lap" outbound
180
+ and "the lap is finished" inbound. The head read the return leg as its own turn,
181
+ re-ran its own layers, forwarded again, and the lap never closed.
182
+
183
+ Every number was correct. Every message round-tripped. **Neither test suite could
184
+ see it** — the pipeline test calls the stages in order itself, and the codec test
185
+ only checks bytes. The defect lived in the *route*, precisely the category
186
+ DaisyChain-Web's own self-corpus writeup identified as needing different
187
+ instruments rather than better oracles. The fix gave the return leg its own
188
+ address, and the routing decision moved out of the event handler into a pure
189
+ function so `walkLap` in `test_wire.js` can walk a lap and assert it visits each
190
+ stage once and terminates. That test fails against the old behaviour; it was
191
+ checked.
192
+
193
+ ## Layout
194
+
195
+ ```
196
+ server.js signaling + static host + OAuth code exchange
197
+ public/safetensors.js header parsing, exact BF16/F16 widening, range coalescing
198
+ public/hf.js Hub client: range reads, gated repos, token redaction
199
+ public/arch.js config.json -> normalized spec; per-family tensor names
200
+ public/tokenizer.js byte-level BPE from the repo's own tokenizer.json
201
+ public/shard.js the plan, per-stage tensor sets, weight-layout handling
202
+ public/infer.js forward pass: embed / runLayers / readout
203
+ public/wire.js binary protocol + routing, as pure functions
204
+ public/app.js WebRTC mesh, assignments, credentials, token loop
205
+ public/verified_core.js the verified INT8 units (unchanged from DaisyChain-Web)
206
+ public/webgpu.js WGSL kernels + exact gates (unchanged)
207
+ public/*.bin the units as lookup tables (unchanged)
208
+ test_pipeline.js split vs unsplit, bit-exact, both families
209
+ test_wire.js protocol round-trips, refusals, ring routing
210
+ test_loader.js safetensors, dtype widening, BPE
211
+ docs/ GETTING_STARTED, ARCHITECTURE
212
+ ```
213
+
214
+ ## Honest limits
215
+
216
+ - **Latency, not bandwidth, is the cost.** Every token pays one round trip per
217
+ stage. More stages buy capacity, not speed — expect tokens/sec to *fall* as you
218
+ add devices.
219
+ - **No KV cache.** Every token re-runs the whole window, which is also what keeps
220
+ the split bit-comparable against an unsplit run.
221
+ - **f32 in memory.** Weights are widened from F16/BF16 on load, so a stage costs
222
+ 4 bytes per parameter it holds.
223
+ - **The head is a single point of failure**, and a stage that drops stalls the
224
+ ring; press Generate again to re-plan around whoever is still connected.
225
+ - **No authentication of activations.** A malicious stage that runs correct math
226
+ but returns a crafted activation is not caught by any of this. Peers see each
227
+ other's IPs, and the head sees your prompt. Ring with people you trust.
228
+ - **Proof of concept**, not a hardened service.
229
+
230
+ ---
231
+
232
+ **License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
233
+
234
+ Built on [DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train)
235
+ and [DaisyChain-Web](https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web).
236
+
237
+ ## Citation
238
+
239
+ ```bibtex
240
+ @misc{byrne2026daisychaininfer,
241
+ title = {DaisyChain-Infer: Layer-Sharded Peer-to-Peer Inference in the Browser},
242
+ author = {Byrne, Dean (Quazim0t0)},
243
+ year = {2026},
244
+ howpublished = {\url{https://huggingface.co/DaisyChainAI/DaisyChain-Infer}},
245
+ note = {Run one model across several devices; pools memory, not just compute}
246
+ }
247
+ ```
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ How a page load becomes one stage of a model, and how a token gets produced by
4
+ several machines that each hold only part of it.
5
+
6
+ ## The pieces
7
+
8
+ | File | Role |
9
+ |---|---|
10
+ | `server.js` | WebSocket **signaling** + static hosting. Never sees a weight, an activation, a prompt, or a token. |
11
+ | `public/safetensors.js` | header parsing, dtype widening, byte-range coalescing. |
12
+ | `public/hf.js` | the Hub client — range reads, gated-repo handling, token redaction. |
13
+ | `public/arch.js` | `config.json` -> a normalized spec, and the tensor names to fetch. |
14
+ | `public/tokenizer.js` | byte-level BPE, read from the repo's own `tokenizer.json`. |
15
+ | `public/shard.js` | the **plan**, per-stage tensor sets, and weight-layout handling. |
16
+ | `public/infer.js` | the forward pass, split into `embed` / `runLayers` / `readout`. |
17
+ | `public/wire.js` | the binary protocol and the routing decisions, as pure functions. |
18
+ | `public/app.js` | the WebRTC mesh, assignments, the token prompt, and the token loop. |
19
+ | `public/verified_core.js` | the **verified INT8 units** — unchanged from DaisyChain-Web. |
20
+ | `public/webgpu.js` | the WGSL kernels and their **exact init gates** — unchanged. |
21
+ | `public/*.bin` | the units as lookup tables — unchanged. |
22
+
23
+ The last three being unchanged is deliberate. The arithmetic is copied, not
24
+ reimplemented, which is what makes the split checkable against the unsplit path
25
+ with `!==` instead of a tolerance.
26
+
27
+ ## Reading a model without downloading it
28
+
29
+ A safetensors file is `[u64 headerLen][JSON header][tensor bytes]`, and the
30
+ header gives every tensor's exact byte range. So loading a model here means
31
+ fetching `config.json`, the tokenizer, and the weight *headers* — a few tens of
32
+ KB regardless of whether the model is 100 MB or 100 GB. After that, every
33
+ tensor's location is known and nothing else has been transferred.
34
+
35
+ `arch.js` turns the config into a normalized spec:
36
+
37
+ ```
38
+ family, layers, hidden, heads, kvHeads, headDim, inter, vocab, maxPos,
39
+ norm ('rms'|'ln'), normEps, act ('silu'|'gelu'), gated, rope, ropeTheta,
40
+ tie, qkvFused, bias, weightLayout ('in_out'|'out_in')
41
+ ```
42
+
43
+ Everything downstream works off that spec and never asks which family a model
44
+ came from. Unknown architectures are refused by name — silently treating one
45
+ block shape as another produces confident nonsense.
46
+
47
+ Two details that are silent when wrong, and so are decided by evidence rather
48
+ than assumption:
49
+
50
+ - **Weight layout.** `torch.nn.Linear` stores `(out, in)`; GPT-2's `Conv1D`
51
+ stores `(in, out)`, which is already the `k x n` the GEMM wants. A wrong
52
+ transpose does not throw. The layout comes from the spec and is applied once,
53
+ at load.
54
+ - **Bias presence.** Qwen2.5 ships q/k/v biases and SmolLM does not, and
55
+ neither says so in `config.json`. Bias is therefore decided by what the
56
+ weight file actually contains, not by a config flag.
57
+
58
+ ## The plan
59
+
60
+ Whoever loads the model is the **head**: it owns the embedding table and so
61
+ does both ends of the pass. It orders devices itself-first, then by peer id,
62
+ and apportions blocks by **measured** capacity — each device times a real GEMM
63
+ through its own live kernel and reports GEMMs/sec, the same self-calibrating
64
+ idea as `cluster.py`'s `capacity_score`, except what is being balanced is
65
+ layers per device rather than batch per device.
66
+
67
+ Apportionment is largest-remainder, so it is a pure function of the capacity
68
+ report: every device derives the same plan from the same inputs, and the plan
69
+ never has to be trusted, only compared. A device that ends up with zero blocks
70
+ is dropped from the ring unless it is the head — an empty hop is pure latency.
71
+
72
+ `unpackAssign` refuses any plan whose stages do not cover every layer exactly
73
+ once, and any plan whose stage 0 is not the head. A gap there would not throw
74
+ at runtime; it would generate fluent text with a layer missing. The repo id is
75
+ validated too — it is interpolated into a Hub URL, so a peer must not be able
76
+ to point this device at an arbitrary path.
77
+
78
+ ## Fetching a slice
79
+
80
+ Each device receives an assignment (model id, revision, spec, plan, its own
81
+ layer range) and then fetches **its own tensors itself**, with its own
82
+ credentials. Weights never travel between peers, which means:
83
+
84
+ - no device needs to hold the whole model, not even the head;
85
+ - a token is never sent over the wire — each device is prompted separately;
86
+ - adjacent byte ranges are coalesced, so a stage costs roughly one request
87
+ rather than nine per layer.
88
+
89
+ ## One token
90
+
91
+ 1. The **head** embeds the last `T` tokens (plus learned positions, for GPT-2)
92
+ and runs its own layers.
93
+ 2. It sends the hidden state to stage 1. Each stage runs its layers and passes
94
+ the result on.
95
+ 3. The **last** stage sends the state back to the head, addressed `RETURN`.
96
+ 4. The head applies the final norm and `lm_head` — for the last position only,
97
+ which at a 150k-token vocabulary turns the largest GEMM in the model into a
98
+ single row — picks a token, and starts the next lap.
99
+
100
+ The head is both stage 0 and the terminus, so the return leg has its own
101
+ address rather than a stage index. Reading it as "stage 0, your turn" was a
102
+ real bug: the head re-ran its own layers and the lap never closed.
103
+
104
+ ## The wire
105
+
106
+ All messages are binary. Sentinels continue DaisyChain-Web's numbering (it uses
107
+ −2…−8) so a client pointed at the wrong server sees an unknown tag rather than
108
+ a valid-looking message of the wrong kind.
109
+
110
+ | Sentinel | Message |
111
+ |---|---|
112
+ | `-5` | fragment — large messages chunked at 48 KB |
113
+ | `-20` | hello: measured capacity, kernel probe hash, backend |
114
+ | `-21` | assignment — model id + your layer range (**no weights**) |
115
+ | `-22` | ready — this stage has fetched and loaded its layers |
116
+ | `-23` | activation hop |
117
+ | `-24` | token emitted (so every device can watch the output) |
118
+ | `-25` | run finished |
119
+
120
+ Activation format:
121
+ `[i32 ACT][i32 seq][i32 tokenIdx][i32 nextIndex][u32 actHash][u32 modelHash][f32 hidden]`
122
+
123
+ The two hashes answer different questions. `actHash` asks whether the payload
124
+ arrived intact. `modelHash` asks whether this activation belongs to the model I
125
+ hold a slice of — not hypothetical, since reloading the head with a different
126
+ model leaves stages out there still holding the old one. It is derived from the
127
+ repo id, revision and tensor index rather than the weights, because no device
128
+ reads all of them.
129
+
130
+ ## Backends
131
+
132
+ Unchanged from DaisyChain-Web: DP4A hardware int8 dot → LUT compute shader →
133
+ CPU mirror, each **exact-gated at init** and demoted on any mismatch. The CPU
134
+ mirror is not an approximation — it produces the same bits, which is what lets
135
+ a phone on CPU and a desktop on WebGPU sit in the same ring.
136
+
137
+ Attention runs per head through the same verified block GEMM, because real
138
+ models have head geometries (grouped-query, non-uniform head dims) that the
139
+ trainer's fused uniform-head kernels do not cover. Every product still goes
140
+ through the units.
141
+
142
+ There is no plain-float path. If the units fail to load, the device does not
143
+ compute.
144
+
145
+ ## What the pipeline changes about verification
146
+
147
+ In data-parallel training every peer computes the same thing, so broken
148
+ arithmetic surfaces as a diverging replica. In a pipeline each stage computes
149
+ something different and nobody repeats it — there is no replica to compare
150
+ against, and a wrong middle stage yields a confident wrong answer.
151
+
152
+ What still works: the **kernel probe** (same seeded GEMM everywhere, so it is
153
+ comparable even when the real work is not), the init gates, and the live-shape
154
+ audit — all of which run per device regardless of what that device is computing.
155
+
156
+ What is new: activation hashes, the model fingerprint on every hop, and the
157
+ **differential check** — the head fetching the whole model and re-running the
158
+ prompt locally, then comparing token ids. It is the only instrument that can
159
+ prove a distributed answer right rather than merely self-consistent, and it is
160
+ offered rather than assumed because it needs a model one device can hold.
docs/GETTING_STARTED.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Getting started with DaisyChain-Infer
2
+
3
+ This is a local project. There is no hosted version — you clone it and run it
4
+ on your own machines.
5
+
6
+ ## Run the server
7
+
8
+ ```bash
9
+ npm install
10
+ npm start # http://localhost:8788
11
+ ```
12
+
13
+ Open it in two tabs to try the ring on one machine, or on two devices to try it
14
+ for real.
15
+
16
+ > **HTTPS matters.** WebGPU and cross-device WebRTC need a *secure context*:
17
+ > `localhost` or HTTPS. Plain `http://192.168.x.x` from another device will not
18
+ > get WebGPU and may not connect at all. For real multi-device runs on your own
19
+ > network, put it behind a tunnel (`cloudflared`, `ngrok`) or a local TLS
20
+ > reverse proxy.
21
+
22
+ ## Pick a model
23
+
24
+ Type any Hugging Face repo id — `owner/name` — and press **Load**.
25
+
26
+ Works with `.safetensors` repos whose architecture is:
27
+
28
+ - **Llama-style**: Llama, Mistral, Qwen2/2.5, SmolLM, TinyLlama
29
+ - **GPT-2-style**: GPT-2 and close relatives
30
+
31
+ Good ones to start with:
32
+
33
+ | Repo | Size | Notes |
34
+ |---|---|---|
35
+ | `HuggingFaceTB/SmolLM-135M` | 513 MB f32 | 30 layers — splits nicely |
36
+ | `openai-community/gpt2` | 523 MB f32 | the GPT-2 path |
37
+ | `Qwen/Qwen2.5-0.5B` | 942 MB f32 | GQA 14/2, and it ships QKV biases |
38
+
39
+ Loading reads only `config.json`, `tokenizer.json` and the weight **headers** —
40
+ a few tens of KB, whatever the model's size. No weights move until you press
41
+ Generate, and then each device fetches only its own layers.
42
+
43
+ Weights are held as **f32**, so budget ~4 bytes per parameter across the group.
44
+ The ring plan shows exactly how many MB each device will download and hold.
45
+
46
+ An unsupported architecture or tokenizer is refused with a message naming what
47
+ it found. That is deliberate: approximating one would produce fluent, confident,
48
+ wrong text.
49
+
50
+ ## Gated or private models
51
+
52
+ You are asked for a Hugging Face token **once**, and only when a request
53
+ actually fails for want of one. Create a **read** token at
54
+ huggingface.co/settings/tokens.
55
+
56
+ The token is held in memory for that tab and nowhere else — not localStorage,
57
+ not sessionStorage, not a cookie, not the URL, never logged, and **never sent
58
+ to another device**. Each device is prompted for its own, because each device
59
+ downloads its own layers. Reloading the tab forgets it, and *Forget token*
60
+ clears it immediately.
61
+
62
+ For a gated model, accept its licence on the model page first — a token alone
63
+ will not get past a licence you have not accepted.
64
+
65
+ ## Rooms
66
+
67
+ Devices on one network group automatically (by public IP, Snapdrop-style). To
68
+ include devices on other networks, everyone opens:
69
+
70
+ ```
71
+ https://<host>/?room=MY-SECRET-CODE
72
+ ```
73
+
74
+ The first person in is the **host** and approves each device individually.
75
+
76
+ ## Run it
77
+
78
+ 1. Wait until the devices see each other in the peer list.
79
+ 2. On **one** device, load the model. That device becomes the **head**: it
80
+ holds the embedding table, embeds your prompt, and turns the returning
81
+ hidden state back into words.
82
+ 3. Check the **ring plan** — who holds which layers, and how much each will
83
+ download.
84
+ 4. Type a prompt and press **Generate**. Each device fetches its layers (this
85
+ takes a moment the first time), reports ready, and the ring starts.
86
+
87
+ | Setting | Default | What it does |
88
+ |---|---|---|
89
+ | Tokens | 60 | how many to generate |
90
+ | Context length | 64 | window size; the main cost per token (there is no KV cache) |
91
+ | Temperature ÷100 | 0 | 0 = greedy, deterministic and therefore checkable |
92
+ | Seed | 1234 | drives sampling above temperature 0, so runs stay reproducible |
93
+ | Verify | off | afterwards, re-run with every layer here and compare |
94
+
95
+ Every device shows the token stream as it arrives — including ones holding a
96
+ few layers and no vocabulary.
97
+
98
+ ## Reading the numbers
99
+
100
+ - **tokens/sec** — the whole ring's rate. Expect it to *fall* as you add
101
+ stages: each one adds a round trip per token. More stages buy capacity, not
102
+ speed.
103
+ - **ring hops** — stages × tokens.
104
+ - **kernel probe** (in the log) — the same number on every honest device,
105
+ whatever its backend. A peer reporting a different one is computing different
106
+ arithmetic, and the log says so.
107
+ - **my slice** — which layers this device holds and how much memory they take.
108
+
109
+ ## Verify
110
+
111
+ Tick **Verify** before generating. Afterwards the head downloads the whole
112
+ model and re-runs the identical prompt locally, then compares token ids:
113
+
114
+ ```
115
+ VERIFIED: the distributed run and the single-device run produced identical token ids.
116
+ ```
117
+
118
+ In a pipeline nothing recomputes anything, so this is the only check that can
119
+ show a distributed answer is *right* rather than merely self-consistent. It
120
+ needs a model one device can hold, which is why it is optional.
121
+
122
+ Without a browser:
123
+
124
+ ```bash
125
+ npm test
126
+ ```
127
+
128
+ ## When it goes wrong
129
+
130
+ - **"the ring stalled at token N"** — a stage went quiet. Press Generate again
131
+ to re-plan around whoever is still connected.
132
+ - **"unsupported architecture"** — the model is not Llama-style or
133
+ GPT-2-style.
134
+ - **"tokenizer type … is not supported"** — the repo uses SentencePiece or
135
+ WordPiece; only byte-level BPE is implemented.
136
+ - **401 / 403** — the model needs a token, or the token lacks access. For
137
+ gated repos, accept the licence on the model page first.
138
+ - **"the server ignored the byte range"** — the whole file came back instead of
139
+ a slice; refused rather than accepted, since that defeats the point.
140
+ - **"REFUSED activation … belongs to model X"** — a stage still holds a slice
141
+ of a previous model. Reload that device.
142
+ - **"⚠ … disagrees with this device's kernel probe"** — that device's
143
+ arithmetic differs from yours. Do not give it layers.
144
+ - **out of memory** — the stage's slice does not fit. Add devices, or pick a
145
+ smaller model; the plan shows the per-device cost before you start.
146
+
147
+ ## More
148
+
149
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — the ring, the plan, the wire protocol.
150
+ - The parent projects:
151
+ [DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train) ·
152
+ [DaisyChain-Web](https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web)
package.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "daisychain-infer",
3
+ "version": "1.0.0",
4
+ "description": "Run one Hugging Face model across several devices — layer-sharded pipeline inference in the browser, peer-to-peer over WebRTC, computed through verified INT8 units. Runs locally.",
5
+ "private": true,
6
+ "main": "server.js",
7
+ "scripts": {
8
+ "start": "node server.js",
9
+ "test": "node test_pipeline.js && node test_wire.js && node test_loader.js"
10
+ },
11
+ "dependencies": {
12
+ "ws": "^8.18.0"
13
+ },
14
+ "license": "MIT",
15
+ "author": "Dean Byrne (Quazim0t0)"
16
+ }
public/app.js ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DaisyChain-Infer client: connect P2P, take a slice of a model straight from
2
+ // the Hub, and run one token loop around the ring.
3
+ //
4
+ // The mesh (signaling, WebRTC, fragmentation, rooms, reconnect) is
5
+ // DaisyChain-Web's, because those are the parts already proven against real
6
+ // phones and real NATs. Everything from "the model" down is new.
7
+ "use strict";
8
+
9
+ const STUN = [
10
+ { urls: "stun:stun.l.google.com:19302" },
11
+ { urls: "turn:openrelay.metered.ca:80", username: "openrelayproject", credential: "openrelayproject" },
12
+ { urls: "turn:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" },
13
+ { urls: "turns:openrelay.metered.ca:443?transport=tcp", username: "openrelayproject", credential: "openrelayproject" },
14
+ ];
15
+ let rtcConfig = { iceServers: STUN };
16
+
17
+ const ui = {};
18
+ for (const id of ["status", "backend", "me", "peers", "log", "requests", "roomInfo", "roomCode",
19
+ "copyLink", "model", "tokenizer", "plan", "myShard", "prompt", "genBtn",
20
+ "stopBtn", "out", "tps", "tokcount", "hops", "temp", "vtemp", "ntok", "vntok",
21
+ "seed", "verify", "verifyRow", "ringState", "repo", "revision", "loadRepo",
22
+ "modelState", "ctxLen", "vctxLen", "tokenState", "clearToken",
23
+ "tokModal", "tokInput", "tokOk", "tokCancel", "tokWhy",
24
+ "tokPasteRow", "tokSignIn", "tokSignInBtn",
25
+ "lobby", "createRoom", "joinRoom", "joinCode"])
26
+ ui[id] = document.getElementById(id);
27
+
28
+ function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${redact(m)}\n` + ui.log.textContent; }
29
+ function setStatus(s) { ui.status.textContent = s; }
30
+
31
+ // ---- the token --------------------------------------------------------------
32
+ // Held in a closure variable and nowhere else. Not localStorage, not
33
+ // sessionStorage, not a cookie, not the URL, not the log, and never on the
34
+ // wire — each device authenticates itself, so a peer never needs anyone
35
+ // else's credentials. Reloading the page is meant to lose it.
36
+ let hfToken = null, tokenSource = "";
37
+ let oauthAvailable = false;
38
+ function redact(s) { return hfToken ? String(s).split(hfToken).join("hf_***") : String(s); }
39
+ function haveToken() { return !!hfToken; }
40
+ function setToken(t, source) {
41
+ hfToken = t || null;
42
+ tokenSource = t ? (source || "entered") : "";
43
+ ui.tokenState.textContent = hfToken
44
+ ? `${tokenSource} · held in memory for this tab only`
45
+ : (oauthAvailable ? "not signed in" : "none");
46
+ ui.clearToken.style.display = hfToken ? "" : "none";
47
+ }
48
+ function clearToken() { setToken(null); log("token cleared from memory"); }
49
+
50
+ // A hosted deployment hands the OAuth token back in the URL *fragment*, which
51
+ // browsers never send to a server. Take it, then strip it from the address bar
52
+ // and from history immediately — a credential sitting in a visible URL is one
53
+ // screenshot away from being shared.
54
+ function adoptTokenFromFragment() {
55
+ if (!location.hash || location.hash.length < 2) return;
56
+ const h = new URLSearchParams(location.hash.slice(1));
57
+ const t = h.get("hf"), err = h.get("oauth_error");
58
+ if (t || err) history.replaceState(null, "", location.pathname + location.search);
59
+ if (err) { log(`sign-in failed: ${err}`); return; }
60
+ if (t) { setToken(t, "signed in with Hugging Face"); log("signed in with Hugging Face — token held in memory for this tab only"); }
61
+ }
62
+
63
+ // One-time prompt. Resolves to a token string, or null if the user declines.
64
+ // Deliberately a modal rather than a stored setting: the moment a credential
65
+ // becomes ambient, it becomes something you forget you granted.
66
+ // Where OAuth is available — a deployment the user does not control — the
67
+ // paste box is not offered at all. Asking someone to type a personal access
68
+ // token into a page served by a third party is a bad pattern even when the
69
+ // code is honest, because the user cannot verify that it is. Signing in hands
70
+ // over a scoped, expiring token instead, and it is the only path shown there.
71
+ let tokenPrompt = null;
72
+ function requestToken(why) {
73
+ if (tokenPrompt) return tokenPrompt;
74
+ ui.tokWhy.textContent = why || "This model needs a Hugging Face token to download.";
75
+ ui.tokPasteRow.style.display = oauthAvailable ? "none" : "";
76
+ ui.tokSignIn.style.display = oauthAvailable ? "" : "none";
77
+ ui.tokOk.style.display = oauthAvailable ? "none" : ""; // nothing to submit
78
+ ui.tokInput.value = "";
79
+ ui.tokModal.style.display = "flex";
80
+ if (!oauthAvailable) ui.tokInput.focus();
81
+ tokenPrompt = new Promise((resolve) => {
82
+ const done = (val) => {
83
+ ui.tokModal.style.display = "none";
84
+ ui.tokInput.value = ""; // do not leave it in the DOM
85
+ ui.tokOk.onclick = null; ui.tokCancel.onclick = null;
86
+ ui.tokInput.onkeydown = null; ui.tokSignInBtn.onclick = null;
87
+ tokenPrompt = null;
88
+ resolve(val);
89
+ };
90
+ ui.tokSignInBtn.onclick = () => {
91
+ // full-page redirect: we come back with the token in the fragment
92
+ log("redirecting to Hugging Face to sign in…");
93
+ location.href = "auth/login";
94
+ };
95
+ ui.tokOk.onclick = () => {
96
+ const v = ui.tokInput.value.trim();
97
+ if (!v) return done(null);
98
+ setToken(v, "entered by hand");
99
+ log("token accepted — kept in memory for this tab only, never sent to peers");
100
+ done(v);
101
+ };
102
+ ui.tokCancel.onclick = () => { log("token request declined"); done(null); };
103
+ ui.tokInput.onkeydown = (e) => { if (e.key === "Enter") ui.tokOk.onclick(); if (e.key === "Escape") ui.tokCancel.onclick(); };
104
+ });
105
+ return tokenPrompt;
106
+ }
107
+
108
+ // Run an HF call, and if it fails for want of credentials ask once, then retry.
109
+ async function withAuth(fn, what) {
110
+ try { return await fn(hfToken); }
111
+ catch (e) {
112
+ if (e && e.kind === "auth") {
113
+ const gated = e.status === 403 ? " It is gated or private" : "";
114
+ const t = await requestToken(oauthAvailable
115
+ ? `${what} needs Hugging Face access.${gated}${gated ? " — for a gated model, accept its licence on the model page first." : ""}`
116
+ : `${what} needs a Hugging Face token.${gated}. Create a READ token at huggingface.co/settings/tokens.`);
117
+ if (!t) throw new Error(`${what}: cancelled — no token provided`);
118
+ return fn(t);
119
+ }
120
+ throw e;
121
+ }
122
+ }
123
+
124
+ // ---- identity ---------------------------------------------------------------
125
+ const ADJ = ["Frosted", "Silver", "Pale", "Winter", "Hollow", "Quiet", "Drifting", "Little",
126
+ "Glacier", "Misty", "Northern", "Still", "Brave", "Snowlit", "Amber"];
127
+ const NOUN = ["Fox", "Hare", "Owl", "Elk", "Marten", "Sparrow", "Otter", "Deer",
128
+ "Ptarmigan", "Pine", "Birch", "Wren", "Fawn", "Moth", "Lynx"];
129
+ const deviceName = ADJ[Math.floor(Math.random() * ADJ.length)] + " " +
130
+ NOUN[Math.floor(Math.random() * NOUN.length)];
131
+
132
+ let myId = null, compute = null, ws = null, L = null, wasDenied = false;
133
+ const pcs = new Map(), chans = new Map(), names = new Map();
134
+ const caps = new Map();
135
+
136
+ // ---- model state ------------------------------------------------------------
137
+ let repoInfo = null; // {repo, revision, spec, tensors, fingerprint} — head only
138
+ let spec = null, plan = null, myStage = null, stageObj = null;
139
+ let tok = null; // tokenizer, loaded from the same repo
140
+ let modelHash = 0;
141
+ let running = false, abortRun = false;
142
+ let probeHash = 0, auditFailure = null;
143
+ const readyStages = new Set();
144
+
145
+ function nmeOf(id) { return names.get(id) || id; }
146
+ function room() { return new URLSearchParams(location.search).get("room"); }
147
+ function updatePeers() {
148
+ if (!names.size) { ui.peers.textContent = "(none yet — you can still run solo)"; return; }
149
+ ui.peers.textContent = [...names.entries()]
150
+ .map(([id, n]) => `${n} ${chans.has(id) ? "✓" : "(connecting…)"}`).join(", ");
151
+ }
152
+ function ctxLen() { return +ui.ctxLen.value; }
153
+
154
+ // ---- signaling + WebRTC -----------------------------------------------------
155
+ function connectSignaling() {
156
+ const proto = location.protocol === "https:" ? "wss" : "ws";
157
+ const params = new URLSearchParams();
158
+ params.set("name", deviceName);
159
+ if (room()) params.set("room", room());
160
+ ws = new WebSocket(`${proto}://${location.host}/?${params}`);
161
+ ws.onopen = () => setStatus(room() ? `connected — private room "${room()}"` : "connected — grouping with devices on your network");
162
+ ws.onclose = () => {
163
+ if (wasDenied) return;
164
+ setStatus("signaling disconnected — reconnecting…");
165
+ setTimeout(connectSignaling, 3000);
166
+ };
167
+ ws.onmessage = async (ev) => {
168
+ const msg = JSON.parse(ev.data);
169
+ if (msg.type === "welcome") {
170
+ myId = msg.id;
171
+ if (msg.room) log(`group: ${msg.room.startsWith("net:") ? "your network" : "private room " + msg.room.replace("room:", "")}`);
172
+ if (msg.host) { log("you host this room — joiners wait for your approval"); setStatus(`hosting private room "${room()}"`); }
173
+ else if (room()) setStatus(`accepted into private room "${room()}"`);
174
+ for (const p of msg.peers) { names.set(p.id, p.name); if (!chans.has(p.id)) initiatePeer(p.id); }
175
+ updatePeers();
176
+ } else if (msg.type === "waiting") {
177
+ setStatus("knocking — waiting for the room's host to let you in…");
178
+ } else if (msg.type === "denied") {
179
+ wasDenied = true; setStatus("the host declined your request to join"); log("join request declined by the host");
180
+ } else if (msg.type === "host") {
181
+ log("the host left — you are now the host of this room"); setStatus(`hosting private room "${room()}"`);
182
+ } else if (msg.type === "join-request") {
183
+ addJoinRequest(msg.id, msg.name);
184
+ } else if (msg.type === "peer-joined") {
185
+ names.set(msg.id, msg.name); updatePeers(); log(`${msg.name} joined`);
186
+ } else if (msg.type === "peer-left") {
187
+ log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); caps.delete(msg.id); updatePeers(); renderPlan();
188
+ } else if (msg.type === "signal") {
189
+ await onSignal(msg.from, msg.data);
190
+ } else if (msg.type === "ping") {
191
+ ws.send(JSON.stringify({ type: "pong" }));
192
+ } else if (msg.type === "relay") {
193
+ onWire(msg.from, bufFromB64(msg.data));
194
+ }
195
+ };
196
+ }
197
+ function signal(to, data) { ws.send(JSON.stringify({ type: "signal", to, data })); }
198
+
199
+ function addJoinRequest(id, name) {
200
+ const row = document.createElement("div");
201
+ row.className = "joinrow";
202
+ const who = document.createElement("span");
203
+ who.textContent = `❄ ${name} wants to join`; // textContent: names come off the wire
204
+ const btn = (label, allow) => {
205
+ const b = document.createElement("button");
206
+ b.textContent = label;
207
+ b.className = allow ? "small" : "small danger";
208
+ b.onclick = () => {
209
+ ws.send(JSON.stringify({ type: "admit", id, allow }));
210
+ row.remove();
211
+ log(allow ? `you let ${name} in` : `you declined ${name}`);
212
+ };
213
+ return b;
214
+ };
215
+ row.append(who, btn("Accept", true), btn("Deny", false));
216
+ ui.requests.appendChild(row);
217
+ }
218
+
219
+ function newPC(peerId) {
220
+ const pc = new RTCPeerConnection(rtcConfig);
221
+ pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
222
+ pc.onconnectionstatechange = () => {
223
+ if (pc.connectionState === "failed") { cleanupPeer(peerId); scheduleReconnect(peerId); }
224
+ if (pc.connectionState === "disconnected")
225
+ setTimeout(() => {
226
+ if (pcs.get(peerId) === pc && pc.connectionState === "disconnected") {
227
+ log(`${nmeOf(peerId)} connection did not recover — redialing`);
228
+ cleanupPeer(peerId); scheduleReconnect(peerId);
229
+ }
230
+ }, 4000);
231
+ };
232
+ pcs.set(peerId, pc);
233
+ return pc;
234
+ }
235
+ function b64FromBuf(buf) {
236
+ const u = new Uint8Array(buf); let s = "";
237
+ for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000));
238
+ return btoa(s);
239
+ }
240
+ function bufFromB64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; }
241
+ function makeRelayChannel(peerId) {
242
+ return {
243
+ isRelay: true,
244
+ get readyState() { return ws && ws.readyState === 1 && names.has(peerId) ? "open" : "closed"; },
245
+ get bufferedAmount() { return ws ? ws.bufferedAmount : 0; },
246
+ send(buf) { ws.send(JSON.stringify({ type: "relay", to: peerId, data: b64FromBuf(buf) })); },
247
+ close() { chans.delete(peerId); },
248
+ };
249
+ }
250
+ const reconnectTimers = new Map();
251
+ function scheduleReconnect(peerId, attempt = 0) {
252
+ if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
253
+ if (attempt >= 5) {
254
+ if (names.has(peerId)) {
255
+ log(`no direct WebRTC path to ${nmeOf(peerId)} after 5 attempts — relaying activations through the server instead`);
256
+ chans.set(peerId, makeRelayChannel(peerId));
257
+ updatePeers(); wake();
258
+ }
259
+ return;
260
+ }
261
+ const delay = 1500 * Math.pow(2, attempt);
262
+ reconnectTimers.set(peerId, setTimeout(() => {
263
+ reconnectTimers.delete(peerId);
264
+ if (!names.has(peerId) || chans.has(peerId)) return;
265
+ if (+myId.slice(1) > +peerId.slice(1)) {
266
+ log(`reconnecting to ${nmeOf(peerId)} (attempt ${attempt + 1})…`);
267
+ cleanupPeer(peerId); initiatePeer(peerId);
268
+ }
269
+ setTimeout(() => scheduleReconnect(peerId, attempt + 1), 6000);
270
+ }, delay));
271
+ }
272
+ function initiatePeer(peerId) {
273
+ const pc = newPC(peerId);
274
+ setupChannel(peerId, pc.createDataChannel("daisy"));
275
+ pc.createOffer().then(o => pc.setLocalDescription(o)).then(() => signal(peerId, { sdp: pc.localDescription }));
276
+ }
277
+ const pendingCand = new Map();
278
+ async function onSignal(from, data) {
279
+ let pc = pcs.get(from);
280
+ if (data.sdp) {
281
+ if (!pc) { pc = newPC(from); pc.ondatachannel = (e) => setupChannel(from, e.channel); }
282
+ await pc.setRemoteDescription(data.sdp);
283
+ for (const c of pendingCand.get(from) || []) try { await pc.addIceCandidate(c); } catch (e) {}
284
+ pendingCand.delete(from);
285
+ if (data.sdp.type === "offer") {
286
+ const ans = await pc.createAnswer(); await pc.setLocalDescription(ans);
287
+ signal(from, { sdp: pc.localDescription });
288
+ }
289
+ } else if (data.candidate) {
290
+ if (pc && pc.remoteDescription) { try { await pc.addIceCandidate(data.candidate); } catch (e) {} }
291
+ else { if (!pendingCand.has(from)) pendingCand.set(from, []); pendingCand.get(from).push(data.candidate); }
292
+ }
293
+ }
294
+ function setupChannel(peerId, dc) {
295
+ dc.binaryType = "arraybuffer";
296
+ dc.onopen = () => { chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); sendHello(peerId); };
297
+ dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
298
+ dc.onmessage = (e) => onWire(peerId, e.data);
299
+ }
300
+ function cleanupPeer(id) {
301
+ const pc = pcs.get(id); if (pc) pc.close();
302
+ pcs.delete(id); chans.delete(id); pendingCand.delete(id);
303
+ for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
304
+ wake();
305
+ }
306
+
307
+ // ---- fragmentation ----------------------------------------------------------
308
+ const FRAG_SENTINEL = Wire.FRAG, FRAG_CHUNK = 48 * 1024, DC_MAXBUF = 4 * 1024 * 1024;
309
+ let fragSeq = 1;
310
+ const fragIn = new Map();
311
+ function dcDrain(dc) {
312
+ return new Promise((res) => {
313
+ if (dc.bufferedAmount <= DC_MAXBUF || dc.readyState !== "open") return res();
314
+ const t = setInterval(() => {
315
+ if (dc.bufferedAmount <= DC_MAXBUF || dc.readyState !== "open") { clearInterval(t); res(); }
316
+ }, 50);
317
+ });
318
+ }
319
+ async function dcSend(dc, buf) {
320
+ if (buf.byteLength <= FRAG_CHUNK) {
321
+ await dcDrain(dc);
322
+ if (dc.readyState === "open") dc.send(buf);
323
+ return;
324
+ }
325
+ const id = fragSeq++, src = new Uint8Array(buf);
326
+ const total = Math.ceil(src.length / FRAG_CHUNK);
327
+ for (let s = 0; s < total; s++) {
328
+ await dcDrain(dc);
329
+ if (dc.readyState !== "open") return;
330
+ const part = src.subarray(s * FRAG_CHUNK, Math.min((s + 1) * FRAG_CHUNK, src.length));
331
+ const msg = new ArrayBuffer(16 + part.length);
332
+ new Int32Array(msg, 0, 4).set([FRAG_SENTINEL, id, s, total]);
333
+ new Uint8Array(msg, 16).set(part);
334
+ dc.send(msg);
335
+ }
336
+ }
337
+ function onFragment(peerId, buf) {
338
+ const [, id, seq, total] = new Int32Array(buf, 0, 4);
339
+ const key = peerId + ":" + id;
340
+ let st = fragIn.get(key);
341
+ if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
342
+ st.parts[seq] = new Uint8Array(buf, 16).slice(0);
343
+ st.got++;
344
+ if (st.got < st.total) return null;
345
+ fragIn.delete(key);
346
+ let len = 0; for (const p of st.parts) len += p.length;
347
+ const out = new Uint8Array(len);
348
+ let off = 0; for (const p of st.parts) { out.set(p, off); off += p.length; }
349
+ return out.buffer;
350
+ }
351
+ function sendTo(peerId, buf) {
352
+ const dc = chans.get(peerId);
353
+ if (!dc || dc.readyState !== "open") return Promise.resolve(false);
354
+ return dcSend(dc, buf).then(() => true);
355
+ }
356
+ function broadcast(buf) {
357
+ return Promise.all([...chans.values()].filter(dc => dc.readyState === "open").map(dc => dcSend(dc, buf)));
358
+ }
359
+
360
+ // ---- hello / capability -----------------------------------------------------
361
+ function sendHello(peerId) {
362
+ return sendTo(peerId, Wire.packHello(myCapacity, probeHash, compute ? compute.backend : "?"));
363
+ }
364
+ function onHello(peerId, buf) {
365
+ let h;
366
+ try { h = Wire.unpackHello(buf); } catch (e) { log(`bad hello from ${nmeOf(peerId)}: ${e.message}`); return; }
367
+ caps.set(peerId, { capacity: h.capacity, backend: h.backend, probe: h.probeHash });
368
+ // The kernel probe matters MORE in a pipeline than in the trainer. There,
369
+ // every peer computes the same thing, so bad arithmetic shows up as a
370
+ // diverging replica. Here each stage computes something different and nobody
371
+ // repeats it — a broken middle stage would corrupt every token invisibly.
372
+ // The probe is the one value that stays comparable when the work is not.
373
+ if (probeHash && h.probeHash && h.probeHash !== probeHash)
374
+ log(`⚠ ${nmeOf(peerId)} disagrees with this device's kernel probe (${h.probeHash} vs ${probeHash}). ` +
375
+ `Their arithmetic differs from ours — do not put them in the ring.`);
376
+ renderPlan();
377
+ }
378
+
379
+ // ---- capacity ---------------------------------------------------------------
380
+ let myCapacity = 1;
381
+ async function measureCapacity() {
382
+ const m = 32, k = 64, n = 64;
383
+ const X = new Float32Array(m * k), W = new Float32Array(k * n);
384
+ for (let i = 0; i < X.length; i++) X[i] = Math.sin(i) * 0.5;
385
+ for (let i = 0; i < W.length; i++) W[i] = Math.cos(i) * 0.5;
386
+ await Verified.vgemmBlock(X, W, { m, k, n, batch: 1 }, L, compute.bgemm, null);
387
+ const t0 = performance.now();
388
+ let it = 0;
389
+ while (performance.now() - t0 < 300) { await Verified.vgemmBlock(X, W, { m, k, n, batch: 1 }, L, compute.bgemm, null); it++; }
390
+ myCapacity = it / ((performance.now() - t0) / 1000);
391
+ return myCapacity;
392
+ }
393
+
394
+ // ---- loading a model from the Hub -------------------------------------------
395
+ // Only the header and config are read here: a few tens of KB, regardless of how
396
+ // large the model is. Weights are fetched per stage, later, by each device.
397
+ async function loadRepo() {
398
+ const repo = ui.repo.value.trim();
399
+ const revision = (ui.revision.value.trim() || "main");
400
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) { log(`"${repo}" is not a valid repo id (expected owner/name)`); return; }
401
+ ui.loadRepo.disabled = true;
402
+ ui.modelState.textContent = "reading config…";
403
+ try {
404
+ const cfg = await withAuth((t) => HF.getJSON(repo, "config.json", revision, t), `${repo}/config.json`);
405
+ const s = Arch.fromConfig(cfg);
406
+ ui.modelState.textContent = "reading tokenizer…";
407
+ const tokJson = await withAuth((t) => HF.getJSON(repo, "tokenizer.json", revision, t), `${repo}/tokenizer.json`);
408
+ tok = Tokenizer.build(tokJson);
409
+ ui.tokenizer.textContent = tok.name;
410
+ ui.modelState.textContent = "reading weight index…";
411
+ const { tensors } = await withAuth((t) => HF.readIndex(repo, revision, t, (m) => { ui.modelState.textContent = m; }),
412
+ `${repo} weights`);
413
+ const fingerprint = Shard.modelFingerprint(repo, revision, tensors);
414
+ repoInfo = { repo, revision, spec: s, tensors, fingerprint };
415
+ spec = s; modelHash = fingerprint;
416
+ const totalMB = [...tensors.values()].reduce((a, t) => a + t.elems * 4, 0) / 1048576;
417
+ ui.model.textContent =
418
+ `${repo} · ${s.family}-style · ${s.layers} layers · hidden ${s.hidden} · ` +
419
+ `heads ${s.heads}${s.kvHeads !== s.heads ? "/" + s.kvHeads + " kv" : ""} · vocab ${s.vocab}`;
420
+ ui.modelState.textContent = `${totalMB.toFixed(0)} MB in f32 · fingerprint ${fingerprint.toString(16)}`;
421
+ log(`model ready: ${repo}@${revision} — ${s.layers} layers, ${totalMB.toFixed(0)} MB total in f32. ` +
422
+ `Nothing has been downloaded yet beyond headers; each device will fetch only its own layers.`);
423
+ if (s.maxPos < ctxLen()) log(`⚠ this model's max position is ${s.maxPos} — lower the context length`);
424
+ ui.genBtn.disabled = false;
425
+ ui.verifyRow.style.display = "";
426
+ renderPlan();
427
+ } catch (e) {
428
+ ui.modelState.textContent = "failed";
429
+ log(`could not load ${repo}: ${redact(e.message)}`);
430
+ }
431
+ ui.loadRepo.disabled = false;
432
+ }
433
+
434
+ function buildPlan() {
435
+ if (!repoInfo) return null;
436
+ const list = [{ id: myId, capacity: myCapacity, backend: compute.backend }];
437
+ for (const id of [...chans.keys()].sort((a, b) => +a.slice(1) - +b.slice(1))) {
438
+ const c = caps.get(id);
439
+ if (c) list.push({ id, capacity: c.capacity, backend: c.backend });
440
+ }
441
+ return Shard.planStages(repoInfo.spec, list);
442
+ }
443
+ function renderPlan() {
444
+ if (!plan) {
445
+ const known = 1 + [...chans.keys()].filter(id => caps.has(id)).length;
446
+ ui.plan.textContent = repoInfo ? `${known} device(s) ready — press Generate to shard and run`
447
+ : "load a model to see how it would be split";
448
+ return;
449
+ }
450
+ ui.plan.textContent = plan.map(s => {
451
+ const who = s.id === myId ? "me" : nmeOf(s.id);
452
+ const part = s.hi > s.lo ? `layers ${s.lo}–${s.hi - 1}` : "—";
453
+ let mb = "";
454
+ if (repoInfo) mb = " · " + (Shard.stageBytes(repoInfo.spec, repoInfo.tensors, s, Arch) / 1048576).toFixed(0) + " MB";
455
+ return `${s.index}. ${who}${s.head ? " (head: embed + lm_head)" : ""} · ${part}${mb}` +
456
+ (readyStages.has(s.index) ? " ✓" : "");
457
+ }).join("\n");
458
+ }
459
+
460
+ // Fetch this device's own layers. This is the only place weight bytes move,
461
+ // and they move from the Hub to here — never from a peer.
462
+ async function loadMyStage(assign, st) {
463
+ const s = assign.spec;
464
+ ui.myShard.textContent = "reading index…";
465
+ const { tensors } = await withAuth((t) => HF.readIndex(assign.repo, assign.revision, t), `${assign.repo} weights`);
466
+ const want = Arch.tensorsFor(s, tensors, st).map(n => tensors.get(n));
467
+ const mb = want.reduce((a, t) => a + t.elems * 4, 0) / 1048576;
468
+ log(`fetching my slice from the Hub: ${want.length} tensors, ${mb.toFixed(0)} MB ` +
469
+ `(the other ${s.layers - (st.hi - st.lo)} layers are never downloaded here)`);
470
+ const got = await withAuth((t) => HF.fetchTensors(assign.repo, assign.revision, t, want,
471
+ (p) => { ui.myShard.textContent = `downloading ${p}`; }),
472
+ `${assign.repo} weights`);
473
+ const w = Shard.stageWeights(s, st, got, Arch);
474
+ stageObj = Infer.makeStage(s, st, w, kernelCtx(), assign.ctx || ctxLen());
475
+ spec = s;
476
+ ui.myShard.textContent = `${st.head ? "head + " : ""}layers ${st.lo}–${st.hi - 1} · ${mb.toFixed(0)} MB resident`;
477
+ return stageObj;
478
+ }
479
+
480
+ async function onAssign(peerId, buf) {
481
+ if (running) { log(`ignored an assignment from ${nmeOf(peerId)} mid-run`); return; }
482
+ let a;
483
+ try { a = Wire.unpackAssign(buf); } catch (e) { log(`REFUSED assignment from ${nmeOf(peerId)}: ${e.message}`); return; }
484
+ plan = a.plan; spec = a.spec; modelHash = a.fingerprint;
485
+ myStage = plan[a.mine];
486
+ log(`assigned by ${nmeOf(peerId)}: ${a.repo}@${a.revision}, layers ${myStage.lo}–${myStage.hi - 1} ` +
487
+ `of ${a.spec.layers} (stage ${a.mine} of ${plan.length})`);
488
+ renderPlan();
489
+ try {
490
+ // tokenizer only matters on the head, but loading it is cheap and lets a
491
+ // middle stage show the token stream
492
+ if (!tok) {
493
+ try { tok = Tokenizer.build(await withAuth((t) => HF.getJSON(a.repo, "tokenizer.json", a.revision, t), "tokenizer")); }
494
+ catch (e) { log(`(no tokenizer here: ${redact(e.message)} — this stage will show ids, not text)`); }
495
+ }
496
+ await loadMyStage(a, myStage);
497
+ await sendTo(peerId, Wire.packReady(a.mine, true, "loaded"));
498
+ log(`ready — holding layers ${myStage.lo}–${myStage.hi - 1}. The rest of the model is not on this device.`);
499
+ setStatus("in the ring — waiting for work");
500
+ } catch (e) {
501
+ await sendTo(peerId, Wire.packReady(a.mine, false, redact(e.message)));
502
+ log(`could not take my slice: ${redact(e.message)}`);
503
+ }
504
+ }
505
+ function onReady(peerId, buf) {
506
+ const r = Wire.unpackReady(buf);
507
+ if (r.ok) { readyStages.add(r.stageIndex); log(`${nmeOf(peerId)} is ready (stage ${r.stageIndex})`); }
508
+ else log(`${nmeOf(peerId)} could not load stage ${r.stageIndex}: ${r.note}`);
509
+ renderPlan(); wake();
510
+ }
511
+
512
+ // ---- the ring ---------------------------------------------------------------
513
+ // One token = one lap. The head embeds the window and hands the hidden state
514
+ // to stage 1; each stage runs its own layers and hands the result on; the last
515
+ // stage returns it to the head, which turns it into a token.
516
+ //
517
+ // The payload is T x hidden floats — tens of KB. The weights it stands in for
518
+ // are hundreds of megabytes. That asymmetry is the whole reason this works,
519
+ // and it makes a run latency-bound rather than bandwidth-bound.
520
+ let runSeq = 0;
521
+ const waiters = new Set();
522
+ function wake() { for (const w of waiters) w(); }
523
+ function waitFor(pred, timeoutMs) {
524
+ return new Promise((resolve) => {
525
+ const t0 = Date.now();
526
+ let timer = null;
527
+ const check = () => {
528
+ const ok = pred();
529
+ if (ok || Date.now() - t0 > timeoutMs) { waiters.delete(check); clearInterval(timer); resolve(!!ok); }
530
+ };
531
+ waiters.add(check);
532
+ timer = setInterval(check, 200);
533
+ check();
534
+ });
535
+ }
536
+ const actIn = new Map();
537
+
538
+ function packAct(seq, tokenIdx, nextIndex, hidden) {
539
+ return Wire.packAct(seq, tokenIdx, nextIndex, hidden, Shard.hashF32, modelHash);
540
+ }
541
+ async function onAct(peerId, buf) {
542
+ let a;
543
+ try { a = Wire.unpackAct(buf, Shard.hashF32); }
544
+ catch (e) { log(`activation from ${nmeOf(peerId)} rejected: ${e.message}`); return; }
545
+ const { seq, tokenIdx, nextIndex, modelHash: mh, hidden } = a;
546
+ if (mh !== modelHash) {
547
+ log(`REFUSED activation from ${nmeOf(peerId)}: it belongs to model ${mh.toString(16)}, this device ` +
548
+ `holds a slice of ${modelHash.toString(16)}. Mixing two models would produce confident nonsense.`);
549
+ return;
550
+ }
551
+ const me = plan && plan.find(s => s.id === myId);
552
+ const kind = Wire.classifyAct(nextIndex, me ? me.index : -99);
553
+ if (kind === "return") { actIn.set(`${seq}:${tokenIdx}`, hidden); wake(); return; }
554
+ if (kind === "other" || !me || !stageObj) return;
555
+ const t0 = performance.now();
556
+ let x;
557
+ try { x = await Infer.runLayers(stageObj, hidden); }
558
+ catch (e) { log(`stage failed: ${redact(e.message)}`); return; }
559
+ if (auditFailure) { log("KERNEL AUDIT FAILED mid-hop — refusing to pass on a value I cannot vouch for"); return; }
560
+ const hop = Wire.routeAfter(plan, me.index);
561
+ ui.ringState.textContent = `layers ${me.lo}–${me.hi - 1} in ${(performance.now() - t0).toFixed(0)} ms → ` +
562
+ `${hop.to === myId ? "me" : nmeOf(hop.to)}${hop.isReturn ? " (return)" : ""}`;
563
+ await sendTo(hop.to, packAct(seq, tokenIdx, hop.address, x));
564
+ }
565
+
566
+ async function generate() {
567
+ if (running) return;
568
+ if (!repoInfo) { log("load a model first — the device that loads it drives the ring"); return; }
569
+ if (!tok) { log("no tokenizer loaded"); return; }
570
+ running = true; abortRun = false; auditFailure = null;
571
+ ui.genBtn.disabled = true; ui.stopBtn.disabled = false;
572
+ readyStages.clear();
573
+ try {
574
+ const T = Math.min(ctxLen(), repoInfo.spec.maxPos);
575
+ plan = buildPlan(); modelHash = repoInfo.fingerprint; spec = repoInfo.spec;
576
+ renderPlan();
577
+ if (plan.length > 1) {
578
+ log(`plan: ${spec.layers} layers over ${plan.length} device(s) — ` +
579
+ plan.map(s => `${s.id === myId ? "me" : nmeOf(s.id)}:${s.hi - s.lo}`).join(", "));
580
+ for (let i = 1; i < plan.length; i++)
581
+ await sendTo(plan[i].id, Wire.packAssign({
582
+ repo: repoInfo.repo, revision: repoInfo.revision, spec, plan,
583
+ mine: i, fingerprint: repoInfo.fingerprint, ctx: T,
584
+ }));
585
+ log("waiting for every stage to fetch its layers…");
586
+ const ok = await waitFor(() => readyStages.size >= plan.length - 1, 600000);
587
+ if (!ok) { log("not every stage reported ready — generating anyway will stall, so stopping here."); throw new Error("stages not ready"); }
588
+ } else {
589
+ log(`solo run — this device will hold all ${spec.layers} layers`);
590
+ }
591
+ myStage = plan[0];
592
+ ui.myShard.textContent = "loading my slice…";
593
+ await loadMyStage({ repo: repoInfo.repo, revision: repoInfo.revision, spec, ctx: T }, myStage);
594
+
595
+ const seq = ++runSeq;
596
+ const nTok = +ui.ntok.value, temp = +ui.temp.value / 100;
597
+ const opts = { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) };
598
+ let ids = [...Tokenizer.encode(tok, ui.prompt.value || "The")];
599
+ if (!ids.length) ids = [0];
600
+ const promptLen = ids.length;
601
+ ui.out.textContent = ui.prompt.value;
602
+ const tStart = performance.now();
603
+ let hops = 0;
604
+
605
+ for (let n = 0; n < nTok && !abortRun; n++) {
606
+ const win = new Int32Array(T);
607
+ const tail = ids.slice(-T);
608
+ for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
609
+ let x = Infer.embed(stageObj, win);
610
+ x = await Infer.runLayers(stageObj, x);
611
+ if (auditFailure) { log(`KERNEL AUDIT FAILED: ${auditFailure} — stopping`); break; }
612
+ if (plan.length > 1) {
613
+ await sendTo(plan[1].id, packAct(seq, n, plan[1].index, x));
614
+ hops += plan.length;
615
+ const key = `${seq}:${n}`;
616
+ const ok = await waitFor(() => actIn.has(key), 60000);
617
+ if (!ok) { log(`the ring stalled at token ${n + 1}: no activation came back. Press Generate again to re-plan.`); break; }
618
+ x = actIn.get(key); actIn.delete(key);
619
+ }
620
+ const id = Infer.pickToken(await Infer.readout(stageObj, x), opts);
621
+ ids.push(id);
622
+ ui.out.textContent = Tokenizer.decode(tok, ids);
623
+ ui.tokcount.textContent = String(n + 1);
624
+ ui.hops.textContent = String(hops);
625
+ ui.tps.textContent = ((n + 1) / ((performance.now() - tStart) / 1000)).toFixed(2);
626
+ broadcast(Wire.packToken(seq, id, ids.length));
627
+ await new Promise(r => setTimeout(r, 0));
628
+ }
629
+ // The differential check. In a pipeline nothing recomputes anything, so
630
+ // this is the only instrument that can show a distributed answer is RIGHT
631
+ // rather than merely self-consistent. It costs a full local run, which is
632
+ // only possible when this device can hold the whole model — so it is
633
+ // offered, not assumed.
634
+ if (ui.verify.checked && !abortRun && plan.length > 1) {
635
+ log("verifying: fetching the whole model here and re-running the same prompt…");
636
+ const all = { id: myId, index: 0, lo: 0, hi: spec.layers, head: true };
637
+ const solo = await loadMyStage({ repo: repoInfo.repo, revision: repoInfo.revision, spec, ctx: T }, all);
638
+ const ref = await Infer.generateLocal([solo], Tokenizer.encode(tok, ui.prompt.value || "The"), nTok,
639
+ { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) });
640
+ const same = ref.length === ids.length && ref.every((v, i) => v === ids[i]);
641
+ log(same ? "VERIFIED: the distributed run and the single-device run produced identical token ids."
642
+ : `MISMATCH: the ring and this device disagree. Distributed "${Tokenizer.decode(tok, ids).slice(0, 60)}" ` +
643
+ `vs local "${Tokenizer.decode(tok, ref).slice(0, 60)}". Check the log for probe warnings.`);
644
+ }
645
+ if (!abortRun) log(`done — ${ids.length - promptLen} token(s) in ${((performance.now() - tStart) / 1000).toFixed(1)} s ` +
646
+ `over ${plan.length} stage(s)`);
647
+ broadcast(Wire.packDone(seq));
648
+ } catch (e) {
649
+ log(`run failed: ${redact(e.message)}`);
650
+ console.error(e);
651
+ }
652
+ running = false;
653
+ ui.genBtn.disabled = false; ui.stopBtn.disabled = true;
654
+ ui.ringState.textContent = "idle";
655
+ }
656
+
657
+ function onToken(peerId, buf) {
658
+ const { id } = Wire.unpackToken(buf);
659
+ if (tok) ui.out.textContent += Tokenizer.decode(tok, [id]);
660
+ else ui.out.textContent += ` ${id}`;
661
+ ui.tokcount.textContent = String(+(ui.tokcount.textContent || 0) + 1);
662
+ }
663
+
664
+ function onWire(peerId, buf) {
665
+ const tag = Wire.tagOf(buf);
666
+ if (tag === Wire.FRAG) { const whole = onFragment(peerId, buf); if (whole) onWire(peerId, whole); return; }
667
+ if (tag === Wire.HELLO) return onHello(peerId, buf);
668
+ if (tag === Wire.ASSIGN) return void onAssign(peerId, buf);
669
+ if (tag === Wire.READY) return onReady(peerId, buf);
670
+ if (tag === Wire.ACT) return void onAct(peerId, buf);
671
+ if (tag === Wire.TOKEN) return onToken(peerId, buf);
672
+ if (tag === Wire.DONE) { ui.ringState.textContent = "idle"; return; }
673
+ }
674
+
675
+ // ---- kernels ----------------------------------------------------------------
676
+ const gemmAudit = {
677
+ cells: 12,
678
+ due: () => true,
679
+ fail: (msg) => { if (!auditFailure) { auditFailure = msg; log(`KERNEL AUDIT FAILED: ${msg}`); } },
680
+ };
681
+ function kernelCtx() {
682
+ return { L, bgemm: compute.bgemm || null, att: compute.att || null,
683
+ mlp: compute.mlp || null, audit: gemmAudit };
684
+ }
685
+
686
+ // ---- boot -------------------------------------------------------------------
687
+ (async function () {
688
+ try {
689
+ const mode = await (await fetch("mode")).json();
690
+ if (mode.rtc) rtcConfig = mode.rtc;
691
+ oauthAvailable = !!mode.oauth;
692
+ // Room-first deployments (the hosted Space sets DAISY_FORCE_ROOMS): there
693
+ // is no LAN auto-grouping between strangers. Two people behind the same
694
+ // CGNAT or on the same campus network share a public IP, and without this
695
+ // they would be dropped into one ring together — able to see each other's
696
+ // addresses and each other's activations. So a visitor with no room code
697
+ // chooses: create their own room, or join one by code.
698
+ if (mode.forceRooms && !room()) {
699
+ for (const c of document.querySelectorAll(".card")) if (c !== ui.lobby) c.style.display = "none";
700
+ ui.lobby.style.display = "";
701
+ ui.createRoom.onclick = () => {
702
+ const code = (ADJ[Math.floor(Math.random() * ADJ.length)] + "-" +
703
+ NOUN[Math.floor(Math.random() * NOUN.length)] + "-" +
704
+ (100 + Math.floor(Math.random() * 900))).toLowerCase();
705
+ location.href = "?room=" + code;
706
+ };
707
+ const join = () => {
708
+ const code = ui.joinCode.value.trim().toLowerCase();
709
+ if (code) location.href = "?room=" + encodeURIComponent(code);
710
+ };
711
+ ui.joinRoom.onclick = join;
712
+ ui.joinCode.onkeydown = (e) => { if (e.key === "Enter") join(); };
713
+ return; // wait for the choice
714
+ }
715
+ } catch (e) {} // no /mode: local defaults
716
+ adoptTokenFromFragment(); // returning from an OAuth sign-in
717
+
718
+ // Same rule as the trainer: the verified units are mandatory. No float path.
719
+ try {
720
+ L = await Compute.loadLUTs();
721
+ if (!(L.mul instanceof Int16Array) || L.mul.length !== 65536) throw new Error("mul8 LUT malformed");
722
+ if (L.mul[((7 & 0xFF) * 256) + (-3 & 0xFF)] !== -21) throw new Error("mul8 LUT self-test failed (7 × -3 ≠ -21)");
723
+ compute = await Compute.initCompute(L);
724
+ } catch (e) {
725
+ setStatus("NEURAL UNITS UNAVAILABLE — inference disabled");
726
+ ui.backend.textContent = "unavailable";
727
+ log(`FATAL: verified neural units failed to load (${e.message}). This build only computes through the units.`);
728
+ return;
729
+ }
730
+ ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label}`;
731
+ probeHash = await Compute.kernelProbe(compute, L);
732
+ log(`kernel probe: ${probeHash} — every honest device gets this same number, on any backend`);
733
+
734
+ await measureCapacity();
735
+ log(`measured capacity: ${myCapacity.toFixed(0)} verified GEMMs/sec — this decides how many layers this device takes`);
736
+
737
+ ui.me.textContent = deviceName;
738
+ if (!hfToken) setToken(null); // keep a token adopted from the fragment
739
+ if (oauthAvailable) log("this deployment uses Hugging Face sign-in for gated models — no token is ever typed into this page");
740
+ for (const [el, out] of [[ui.temp, ui.vtemp], [ui.ntok, ui.vntok], [ui.ctxLen, ui.vctxLen]])
741
+ el.oninput = () => out.textContent = el.value;
742
+ ui.temp.oninput(); ui.ntok.oninput(); ui.ctxLen.oninput();
743
+
744
+ if (room()) {
745
+ ui.roomInfo.style.display = "";
746
+ ui.roomCode.textContent = room();
747
+ ui.copyLink.onclick = async () => {
748
+ try { await navigator.clipboard.writeText(location.href); ui.copyLink.textContent = "Copied!"; }
749
+ catch { ui.copyLink.textContent = location.href; }
750
+ setTimeout(() => ui.copyLink.textContent = "Copy invite link", 2000);
751
+ };
752
+ }
753
+ ui.loadRepo.onclick = loadRepo;
754
+ ui.repo.onkeydown = (e) => { if (e.key === "Enter") loadRepo(); };
755
+ ui.clearToken.onclick = clearToken;
756
+ ui.genBtn.onclick = generate;
757
+ ui.stopBtn.onclick = () => { abortRun = true; log("stopping after the current token"); };
758
+
759
+ updatePeers(); renderPlan();
760
+ connectSignaling();
761
+ document.addEventListener("visibilitychange", () => {
762
+ if (!document.hidden && (!ws || ws.readyState > 1)) { log("page woke — reconnecting"); connectSignaling(); }
763
+ });
764
+ if (navigator.connection && navigator.connection.addEventListener)
765
+ navigator.connection.addEventListener("change", () => {
766
+ if (!ws || ws.readyState > 1) { log("network changed — reconnecting"); connectSignaling(); }
767
+ });
768
+ setStatus("ready — load a model to drive, or wait to be given a slice");
769
+ log(`ready — this device is "${deviceName}", computing through verified units on ${compute.backend.toUpperCase()}`);
770
+ })();
public/arch.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // config.json -> a normalized model spec, and the tensor names to fetch.
2
+ //
3
+ // "Load any model off HuggingFace" is not a file-format problem, it is an
4
+ // architecture problem: the weights are easy to read, but a Llama block and a
5
+ // GPT-2 block are different computations. This file is where that difference
6
+ // is confined. Everything downstream — sharding, the ring, the kernels —
7
+ // works off the normalized spec and never asks what family a model came from.
8
+ //
9
+ // Supported families, and what each one needs from the forward pass:
10
+ //
11
+ // llama / mistral / qwen2 / smollm / tinyllama RMSNorm, RoPE, GQA, SwiGLU
12
+ // gpt2 / gpt2-style LayerNorm+bias, learned
13
+ // positions, fused QKV, GELU
14
+ //
15
+ // Anything else is refused BY NAME with a clear message. Silently treating an
16
+ // unknown architecture as a known one would produce fluent, confident, wrong
17
+ // output — the exact failure this project spends its verification budget on
18
+ // making impossible.
19
+ (function (root) {
20
+ "use strict";
21
+
22
+ const LLAMA_LIKE = new Set(["llama", "mistral", "qwen2", "qwen3", "smollm", "tinyllama", "olmo", "phi3"]);
23
+ const GPT2_LIKE = new Set(["gpt2", "gpt_neo"]);
24
+
25
+ function pick(cfg, keys, dflt) {
26
+ for (const k of keys) if (cfg[k] != null) return cfg[k];
27
+ return dflt;
28
+ }
29
+
30
+ function fromConfig(cfg) {
31
+ const type = String(cfg.model_type || "").toLowerCase();
32
+ const family = LLAMA_LIKE.has(type) ? "llama" : GPT2_LIKE.has(type) ? "gpt2" : null;
33
+ if (!family)
34
+ throw new Error(`unsupported architecture "${cfg.model_type || "unknown"}". ` +
35
+ `This build implements Llama-style (RMSNorm/RoPE/GQA/SwiGLU) and GPT-2-style blocks. ` +
36
+ `Running a different block shape through these kernels would produce confident nonsense, so it is refused.`);
37
+
38
+ const hidden = pick(cfg, ["hidden_size", "n_embd"], 0);
39
+ const layers = pick(cfg, ["num_hidden_layers", "n_layer"], 0);
40
+ const heads = pick(cfg, ["num_attention_heads", "n_head"], 0);
41
+ const vocab = pick(cfg, ["vocab_size"], 0);
42
+ if (!hidden || !layers || !heads || !vocab)
43
+ throw new Error("config.json is missing hidden_size / num_hidden_layers / num_attention_heads / vocab_size");
44
+
45
+ const kvHeads = family === "llama" ? pick(cfg, ["num_key_value_heads"], heads) : heads;
46
+ const headDim = pick(cfg, ["head_dim"], Math.floor(hidden / heads));
47
+ if (heads * headDim !== hidden && family === "gpt2")
48
+ throw new Error(`head geometry does not divide the hidden size (${heads} heads x ${headDim} != ${hidden})`);
49
+
50
+ const spec = {
51
+ family, modelType: type, hidden, layers, heads, kvHeads, headDim, vocab,
52
+ inter: pick(cfg, ["intermediate_size", "n_inner"], family === "gpt2" ? 4 * hidden : 4 * hidden),
53
+ maxPos: pick(cfg, ["max_position_embeddings", "n_positions", "n_ctx"], 2048),
54
+ normEps: pick(cfg, ["rms_norm_eps", "layer_norm_epsilon", "layer_norm_eps"], 1e-5),
55
+ norm: family === "llama" ? "rms" : "ln",
56
+ act: family === "llama" ? "silu" : "gelu",
57
+ gated: family === "llama", // SwiGLU has a gate branch; GELU MLP does not
58
+ rope: family === "llama",
59
+ ropeTheta: pick(cfg, ["rope_theta"], 10000),
60
+ tie: pick(cfg, ["tie_word_embeddings"], family === "gpt2"),
61
+ qkvFused: family === "gpt2", // GPT-2 packs q,k,v in one c_attn
62
+ bias: family === "gpt2", // Llama-likes are bias-free
63
+ // GPT-2's Conv1D stores (in, out) — already the k x n our GEMM wants.
64
+ // torch.nn.Linear stores (out, in) and must be transposed at load.
65
+ weightLayout: family === "gpt2" ? "in_out" : "out_in",
66
+ };
67
+ if (spec.gated && !cfg.intermediate_size)
68
+ throw new Error("a gated MLP needs intermediate_size in config.json");
69
+ if (spec.kvHeads && spec.heads % spec.kvHeads !== 0)
70
+ throw new Error(`num_attention_heads (${spec.heads}) is not a multiple of num_key_value_heads (${spec.kvHeads})`);
71
+ return spec;
72
+ }
73
+
74
+ // ---- tensor names -----------------------------------------------------------
75
+ // Returned as { role: name }. Roles are what the forward pass asks for, so a
76
+ // new family only has to be named here, never plumbed through.
77
+ function layerTensors(spec, i) {
78
+ if (spec.family === "llama") {
79
+ const p = `model.layers.${i}.`;
80
+ // The q/k/v biases are listed but OPTIONAL, and that is not a detail:
81
+ // Qwen2.5 ships them while SmolLM does not, and neither says so in
82
+ // config.json (`attention_bias` is simply absent from Qwen's). Trusting
83
+ // the config would silently drop three bias vectors per layer — a model
84
+ // that still generates fluent text, just not the right text. So bias is
85
+ // decided by what the weight file actually contains.
86
+ return {
87
+ nrm1: p + "input_layernorm.weight",
88
+ nrm2: p + "post_attention_layernorm.weight",
89
+ Wq: p + "self_attn.q_proj.weight", bq: p + "self_attn.q_proj.bias",
90
+ Wk: p + "self_attn.k_proj.weight", bk: p + "self_attn.k_proj.bias",
91
+ Wv: p + "self_attn.v_proj.weight", bv: p + "self_attn.v_proj.bias",
92
+ Wo: p + "self_attn.o_proj.weight", bo: p + "self_attn.o_proj.bias",
93
+ Wgate: p + "mlp.gate_proj.weight", Wup: p + "mlp.up_proj.weight", Wdown: p + "mlp.down_proj.weight",
94
+ };
95
+ }
96
+ const p = `h.${i}.`; // gpt2 (also seen as transformer.h.N)
97
+ return {
98
+ nrm1: p + "ln_1.weight", nrm1b: p + "ln_1.bias",
99
+ nrm2: p + "ln_2.weight", nrm2b: p + "ln_2.bias",
100
+ Wqkv: p + "attn.c_attn.weight", bqkv: p + "attn.c_attn.bias",
101
+ Wo: p + "attn.c_proj.weight", bo: p + "attn.c_proj.bias",
102
+ Wfc: p + "mlp.c_fc.weight", bfc: p + "mlp.c_fc.bias",
103
+ Wdown: p + "mlp.c_proj.weight", bdown: p + "mlp.c_proj.bias",
104
+ };
105
+ }
106
+ function headTensors(spec) {
107
+ if (spec.family === "llama")
108
+ return { emb: "model.embed_tokens.weight", nrmF: "model.norm.weight",
109
+ lmHead: spec.tie ? null : "lm_head.weight" };
110
+ return { emb: "wte.weight", pos: "wpe.weight",
111
+ nrmF: "ln_f.weight", nrmFb: "ln_f.bias",
112
+ lmHead: spec.tie ? null : "lm_head.weight" };
113
+ }
114
+
115
+ // Repos differ on whether GPT-2 tensors carry a "transformer." prefix, and
116
+ // some Llama exports drop "model.". Rather than guessing, resolve each name
117
+ // against the tensors the file actually contains.
118
+ function resolver(available) {
119
+ const has = (n) => available.has(n);
120
+ return function resolve(name, optional) {
121
+ if (name == null) return null;
122
+ if (has(name)) return name;
123
+ for (const alt of ["transformer." + name, name.replace(/^model\./, ""), "model." + name,
124
+ name.replace(/^transformer\./, "")])
125
+ if (has(alt)) return alt;
126
+ if (optional) return null;
127
+ throw new Error(`the weights do not contain "${name}" — this repo's layout is not one this build recognises`);
128
+ };
129
+ }
130
+
131
+ // Every tensor one stage needs: its layers, plus the head's pieces if it is
132
+ // the head. This is the list that becomes byte ranges.
133
+ function tensorsFor(spec, available, st) {
134
+ const resolve = resolver(available);
135
+ const want = [];
136
+ const add = (n, opt) => { const r = resolve(n, opt); if (r) want.push(r); };
137
+ if (st.head) {
138
+ const h = headTensors(spec);
139
+ add(h.emb); add(h.pos, true); add(h.nrmF, true); add(h.nrmFb, true); add(h.lmHead, true);
140
+ }
141
+ for (let l = st.lo; l < st.hi; l++) {
142
+ const t = layerTensors(spec, l);
143
+ // bias roles (b*) are optional everywhere: present in the file or not
144
+ // applied at all. Weight roles (W*, nrm*) are required.
145
+ for (const [role, n] of Object.entries(t)) add(n, /^b/.test(role));
146
+ }
147
+ return want;
148
+ }
149
+
150
+ const api = { fromConfig, layerTensors, headTensors, tensorsFor, resolver, LLAMA_LIKE, GPT2_LIKE };
151
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
152
+ else root.Arch = api;
153
+ })(typeof self !== "undefined" ? self : this);
public/hf.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Hugging Face Hub client — fetch only the bytes this device needs.
2
+ //
3
+ // Two rules shape this file.
4
+ //
5
+ // 1. NOTHING is downloaded whole. config.json and the tokenizer are small and
6
+ // fetched normally; the weights are read with HTTP range requests against
7
+ // the byte offsets in the safetensors header. A device that owns four
8
+ // layers transfers four layers.
9
+ //
10
+ // 2. The token is asked for ONCE, held in memory, and never written anywhere.
11
+ // Not localStorage, not sessionStorage, not a cookie, not a URL, not the
12
+ // log, and above all never onto the wire — peers each authenticate
13
+ // themselves. A page reload is meant to lose it. See requestToken() in
14
+ // app.js for the prompt; this file only ever receives it as an argument.
15
+ (function (root) {
16
+ "use strict";
17
+
18
+ let ST; // Safetensors — resolved per environment at the end
19
+ const HUB = "https://huggingface.co";
20
+
21
+ // A token that reaches a log or a peer is a leaked credential, so anything
22
+ // that formats an error goes through here first. Cheap, and it means a
23
+ // careless template literal cannot undo rule 2.
24
+ function redact(s, token) {
25
+ if (!token) return s;
26
+ return String(s).split(token).join("hf_***");
27
+ }
28
+
29
+ function headers(token, extra) {
30
+ const h = Object.assign({}, extra || {});
31
+ if (token) h["Authorization"] = "Bearer " + token;
32
+ return h;
33
+ }
34
+
35
+ function fileUrl(repo, file, revision) {
36
+ return `${HUB}/${repo}/resolve/${encodeURIComponent(revision || "main")}/${file}`;
37
+ }
38
+
39
+ // Turn the Hub's status codes into something a person can act on. 401/403 on
40
+ // a public-looking repo almost always means "gated, accept the licence" or
41
+ // "private, needs a token", and those need different actions from the user,
42
+ // so they are not collapsed into one message.
43
+ class HFError extends Error {
44
+ constructor(msg, kind, status) { super(msg); this.kind = kind; this.status = status; }
45
+ }
46
+ function statusError(res, repo, file, hasToken) {
47
+ const where = `${repo}/${file}`;
48
+ if (res.status === 401)
49
+ return new HFError(hasToken
50
+ ? `${where}: the token was rejected (401). Check it is a valid READ token and has not been revoked.`
51
+ : `${where} needs authentication (401).`, "auth", 401);
52
+ if (res.status === 403)
53
+ return new HFError(hasToken
54
+ ? `${where}: this token does not have access (403). If the model is gated, accept its licence on the model page first.`
55
+ : `${where} is gated or private (403).`, "auth", 403);
56
+ if (res.status === 404)
57
+ return new HFError(`${where} not found (404). Check the repo id, and that this model ships .safetensors weights.`, "missing", 404);
58
+ return new HFError(`${where}: HTTP ${res.status}`, "http", res.status);
59
+ }
60
+
61
+ async function getJSON(repo, file, revision, token) {
62
+ const res = await fetch(fileUrl(repo, file, revision), { headers: headers(token) });
63
+ if (!res.ok) throw statusError(res, repo, file, !!token);
64
+ try { return await res.json(); }
65
+ catch (e) { throw new HFError(`${repo}/${file} is not valid JSON`, "parse", res.status); }
66
+ }
67
+
68
+ // Range read. The Hub redirects to a CDN that honours Range and answers 206;
69
+ // a 200 here means the whole file came back, which for a multi-hundred-MB
70
+ // weights file is exactly the outcome this project exists to avoid — so it
71
+ // is treated as an error rather than silently accepted.
72
+ async function getRange(repo, file, revision, token, start, end) {
73
+ const res = await fetch(fileUrl(repo, file, revision), {
74
+ headers: headers(token, { Range: `bytes=${start}-${end - 1}` }),
75
+ });
76
+ if (res.status === 200)
77
+ throw new HFError(`${repo}/${file}: the server ignored the byte range and returned the whole file — refusing it.`, "norange", 200);
78
+ if (!res.ok && res.status !== 206) throw statusError(res, repo, file, !!token);
79
+ return res.arrayBuffer();
80
+ }
81
+
82
+ // ---- what a repo contains ---------------------------------------------------
83
+ // Sharded repos carry model.safetensors.index.json mapping tensor -> shard.
84
+ // Single-file repos just have model.safetensors. Try the index first: its
85
+ // absence is the normal case and a 404 is cheap.
86
+ async function listWeightFiles(repo, revision, token) {
87
+ try {
88
+ const idx = await getJSON(repo, "model.safetensors.index.json", revision, token);
89
+ const files = [...new Set(Object.values(idx.weight_map || {}))];
90
+ if (!files.length) throw new HFError("weight index lists no shards", "parse", 200);
91
+ return { files, weightMap: idx.weight_map };
92
+ } catch (e) {
93
+ if (e.kind === "auth") throw e; // don't mask a token problem
94
+ return { files: ["model.safetensors"], weightMap: null };
95
+ }
96
+ }
97
+
98
+ // Read just the header of each weight shard: two requests per shard, a few
99
+ // tens of KB, and afterwards we know every tensor's exact byte range without
100
+ // having touched a single weight.
101
+ async function readIndex(repo, revision, token, onProgress) {
102
+ const { files } = await listWeightFiles(repo, revision, token);
103
+ const tensors = new Map();
104
+ for (const f of files) {
105
+ if (onProgress) onProgress(`reading ${f} header…`);
106
+ const head = await getRange(repo, f, revision, token, 0, 8);
107
+ const dv = new DataView(head);
108
+ const lo = dv.getUint32(0, true), hi = dv.getUint32(4, true);
109
+ if (hi !== 0) throw new HFError(`${f}: implausible header length — not a safetensors file`, "parse", 200);
110
+ const full = await getRange(repo, f, revision, token, 0, 8 + lo);
111
+ const parsed = ST.parseHeader(full);
112
+ for (const [name, t] of parsed.tensors) tensors.set(name, Object.assign({ file: f }, t));
113
+ }
114
+ return { files, tensors };
115
+ }
116
+
117
+ // Fetch a specific set of tensors as f32, coalescing adjacent byte ranges.
118
+ // This is the only function that moves real weight data.
119
+ async function fetchTensors(repo, revision, token, tensors, onProgress) {
120
+ const out = new Map();
121
+ const byFile = new Map();
122
+ for (const t of tensors) {
123
+ if (!byFile.has(t.file)) byFile.set(t.file, []);
124
+ byFile.get(t.file).push(t);
125
+ }
126
+ let done = 0;
127
+ const total = tensors.reduce((a, t) => a + t.bytes, 0);
128
+ for (const [file, list] of byFile) {
129
+ for (const run of ST.coalesce(list)) {
130
+ const buf = await getRange(repo, file, revision, token, run.start, run.end);
131
+ for (const t of run.tensors) {
132
+ const off = t.start - run.start;
133
+ out.set(t.name, ST.toF32(t.dtype, new Uint8Array(buf, off, t.bytes)));
134
+ done += t.bytes;
135
+ if (onProgress) onProgress(`${(done / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MB`);
136
+ }
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // A cheap probe so the UI can ask for a token only when one is actually
143
+ // needed, instead of demanding credentials up front for public models.
144
+ async function needsAuth(repo, revision) {
145
+ try {
146
+ const res = await fetch(fileUrl(repo, "config.json", revision), { method: "HEAD" });
147
+ return res.status === 401 || res.status === 403;
148
+ } catch (e) { return false; }
149
+ }
150
+
151
+ const api = { HUB, HFError, fileUrl, getJSON, getRange, listWeightFiles, readIndex,
152
+ fetchTensors, needsAuth, redact };
153
+ if (typeof module !== "undefined" && module.exports) { ST = require("./safetensors.js"); module.exports = api; }
154
+ else { ST = root.Safetensors; root.HF = api; }
155
+ })(typeof self !== "undefined" ? self : this);
public/index.html ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DaisyChain-Infer — run one model across your devices</title>
7
+ <style>
8
+ /* Winter palette: pale snow-light, deep glacier dark. Blues and slates
9
+ throughout, with a cold cyan accent that stays legible on both. */
10
+ :root {
11
+ --page-bg: #eef3f8;
12
+ --page-tint: #dce6f0;
13
+ --card-bg: #f9fbfd;
14
+ --card-border: rgba(90, 122, 154, 0.28);
15
+ --text: #16232e;
16
+ --text-soft: #55708a;
17
+ --accent: #2f6f96;
18
+ --accent-deep: #1d4b6b;
19
+ --panel-bg: linear-gradient(135deg, #24455e 0%, #14283a 100%);
20
+ --panel-num: #eaf4fb;
21
+ --panel-label: #8fb4cd;
22
+ --btn: linear-gradient(135deg, #35789e 0%, #1d4b6b 100%);
23
+ --btn-hover: linear-gradient(135deg, #4189af 0%, #255a7f 100%);
24
+ --danger: #9a3d3d;
25
+ --warn: #a2472f;
26
+ --track: rgba(90, 122, 154, 0.20);
27
+ --shadow: 0 2px 12px rgba(30, 60, 90, 0.10);
28
+ }
29
+ @media (prefers-color-scheme: dark) {
30
+ :root {
31
+ --page-bg: #0c141d;
32
+ --page-tint: #101c27;
33
+ --card-bg: #14212d;
34
+ --card-border: rgba(140, 180, 210, 0.22);
35
+ --text: #dce9f3;
36
+ --text-soft: #8faec7;
37
+ --accent: #6fb5da;
38
+ --accent-deep: #9fd0e8;
39
+ --panel-bg: linear-gradient(135deg, #16303f 0%, #0a1620 100%);
40
+ --panel-num: #e6f3fb;
41
+ --panel-label: #7ea9c4;
42
+ --btn: linear-gradient(135deg, #2c6688 0%, #17394f 100%);
43
+ --btn-hover: linear-gradient(135deg, #37789d 0%, #1e4a66 100%);
44
+ --danger: #b3554f;
45
+ --warn: #e0a07f;
46
+ --track: rgba(140, 180, 210, 0.18);
47
+ --shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
48
+ }
49
+ }
50
+ * { box-sizing: border-box; }
51
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
52
+ max-width: 760px; margin: 0 auto; padding: 24px 18px 48px; line-height: 1.5;
53
+ background: linear-gradient(180deg, var(--page-tint) 0%, var(--page-bg) 340px);
54
+ color: var(--text); }
55
+ h1 { margin: 0 0 2px; font-size: 1.7rem; letter-spacing: .2px; }
56
+ .sub { color: var(--text-soft); margin: 0 0 18px; font-size: .92rem; }
57
+ .sub code, code { background: rgba(47, 111, 150, .12); padding: 1px 5px; border-radius: 4px;
58
+ font-family: 'Cascadia Code', 'Courier New', monospace; font-size: .9em; }
59
+ .card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px;
60
+ padding: 14px 16px; margin: 12px 0; box-shadow: var(--shadow); }
61
+ .lbl { color: var(--text-soft); font-size: 11px; font-weight: 800; letter-spacing: 1.5px;
62
+ text-transform: uppercase; margin-bottom: 8px; }
63
+ .row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; font-size: .95rem; }
64
+ .k { color: var(--text-soft); } .v { font-weight: 700; text-align: right; word-break: break-word; }
65
+ .device { font-family: 'Cascadia Code', 'Courier New', monospace; font-size: 1.5rem; font-weight: 700;
66
+ color: var(--accent-deep); }
67
+ .panel { background: var(--panel-bg); border-radius: 10px; padding: 12px 16px; text-align: center; flex: 1; }
68
+ .panel .num { font-family: 'Cascadia Code', 'Courier New', monospace; font-size: 1.7rem;
69
+ font-weight: 700; color: var(--panel-num); }
70
+ .panel .cl { color: var(--panel-label); font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; }
71
+ button { background: var(--btn); color: #eef6fb; border: 0; border-radius: 8px; padding: 11px 24px;
72
+ font-weight: 700; font-size: .98rem; letter-spacing: .3px; cursor: pointer;
73
+ box-shadow: 0 2px 8px rgba(20, 45, 70, 0.22); transition: .15s; }
74
+ button:hover:not(:disabled) { background: var(--btn-hover); }
75
+ button:disabled { background: #7d8f9e; opacity: .5; cursor: not-allowed; box-shadow: none; }
76
+ button.small { padding: 6px 14px; font-size: .85rem; }
77
+ button.danger { background: var(--danger); }
78
+ pre { background: rgba(20, 45, 70, 0.05); border-radius: 8px; padding: 10px; max-height: 180px; overflow: auto;
79
+ font-size: .78rem; color: var(--text-soft); white-space: pre-wrap; margin: 0;
80
+ font-family: 'Cascadia Code', 'Courier New', monospace; }
81
+ @media (prefers-color-scheme: dark) { pre { background: rgba(0, 0, 0, 0.28); } }
82
+ .note { color: var(--text-soft); font-size: .82rem; }
83
+ .note b { color: var(--warn); }
84
+ .slbl { display: flex; justify-content: space-between; font-size: .92rem; margin: 10px 0 4px; font-weight: 600; }
85
+ .sval { font-family: 'Cascadia Code', 'Courier New', monospace; color: var(--accent); font-weight: 700; }
86
+ input[type=range] { width: 100%; accent-color: var(--accent); }
87
+ input[type=text], input[type=number], input[type=password] {
88
+ padding: 10px 12px; border-radius: 8px; border: 1px solid var(--card-border);
89
+ background: rgba(255,255,255,.55); color: inherit;
90
+ font-family: 'Cascadia Code', 'Courier New', monospace; font-size: .92rem; }
91
+ @media (prefers-color-scheme: dark) {
92
+ input[type=text], input[type=number], input[type=password] { background: rgba(0,0,0,.25); }
93
+ }
94
+ input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
95
+ #out { background: rgba(20, 45, 70, 0.05); border-radius: 8px; padding: 12px; min-height: 96px;
96
+ max-height: 340px; overflow: auto; white-space: pre-wrap; word-break: break-word;
97
+ font-family: 'Cascadia Code', 'Courier New', monospace; font-size: .9rem; color: var(--text); }
98
+ @media (prefers-color-scheme: dark) { #out { background: rgba(0, 0, 0, 0.28); } }
99
+ .joinrow { display: flex; gap: 8px; align-items: center; justify-content: space-between;
100
+ margin-top: 10px; flex-wrap: wrap; }
101
+ .modal { position: fixed; inset: 0; background: rgba(8, 18, 28, .62); backdrop-filter: blur(3px);
102
+ display: none; align-items: center; justify-content: center; padding: 20px; z-index: 50; }
103
+ .modal .box { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px;
104
+ padding: 20px; max-width: 460px; width: 100%; box-shadow: 0 10px 40px rgba(0,0,0,.35); }
105
+ .modal h3 { margin: 0 0 8px; font-size: 1.1rem; }
106
+ .modal input { width: 100%; margin: 12px 0 6px; }
107
+ .modal .acts { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <h1>❄ DaisyChain-Infer</h1>
112
+ <p class="sub">Run <b>one model across several devices</b>. Each device downloads and holds only its <b>own layers</b> — straight from Hugging Face, never from a peer — and passes the hidden state around the ring over WebRTC. The group can run a model that no single device could hold. Every multiply goes through the verified INT8 units. Runs locally: <code>npm start</code>.</p>
113
+
114
+ <!-- Shown only when the deployment sets DAISY_FORCE_ROOMS (the hosted Space).
115
+ Without it, visitors with no room code are grouped by public IP — which
116
+ on a public URL would put strangers behind the same CGNAT in one ring. -->
117
+ <div class="card" id="lobby" style="display:none;text-align:center">
118
+ <div class="lbl">❄ Get started</div>
119
+ <p class="note" style="margin:0 0 12px">Create a room and invite your own devices, or join a room someone shared with you. Rings are private by invitation — you approve every device that joins.</p>
120
+ <button id="createRoom">Create a room</button>
121
+ <div style="display:flex;gap:8px;justify-content:center;margin-top:12px;flex-wrap:wrap">
122
+ <input type="text" id="joinCode" placeholder="room code" spellcheck="false">
123
+ <button id="joinRoom" style="padding:10px 18px">Join</button>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="card">
128
+ <div class="lbl">❄ This device</div>
129
+ <div class="device" id="me">—</div>
130
+ <div class="row" style="margin-top:8px"><span class="k">Status</span><span class="v" id="status">starting…</span></div>
131
+ <div class="row"><span class="k">Compute</span><span class="v" id="backend">detecting…</span></div>
132
+ <div class="row"><span class="k">My slice</span><span class="v" id="myShard">none yet</span></div>
133
+ <div class="row"><span class="k">HF token</span><span class="v" id="tokenState">none</span></div>
134
+ <div style="text-align:right"><button id="clearToken" class="small danger" style="display:none">Forget token</button></div>
135
+ </div>
136
+
137
+ <div class="card">
138
+ <div class="lbl">🜂 Devices in your group</div>
139
+ <div class="row"><span class="v" id="peers" style="text-align:left">(none yet)</span></div>
140
+ <div id="roomInfo" style="display:none;margin-top:8px">
141
+ <div class="row"><span class="k">Room</span><span class="v" id="roomCode"></span></div>
142
+ <div style="text-align:center;margin-top:6px"><button id="copyLink" class="small">Copy invite link</button></div>
143
+ <p class="note" style="margin:.5rem 0 0;text-align:center">Open the link on your other devices — you approve each one before it joins.</p>
144
+ </div>
145
+ <div id="requests"></div>
146
+ </div>
147
+
148
+ <div class="card">
149
+ <div class="lbl">🗻 Model</div>
150
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
151
+ <input type="text" id="repo" value="HuggingFaceTB/SmolLM-135M" spellcheck="false"
152
+ placeholder="owner/name" style="flex:2;min-width:200px">
153
+ <input type="text" id="revision" value="main" spellcheck="false" placeholder="revision" style="flex:0 0 90px">
154
+ <button id="loadRepo">Load</button>
155
+ </div>
156
+ <div class="row" style="margin-top:10px"><span class="v" id="model" style="text-align:left">no model loaded</span></div>
157
+ <div class="row"><span class="k">Status</span><span class="v" id="modelState">—</span></div>
158
+ <div class="row"><span class="k">Tokenizer</span><span class="v" id="tokenizer">—</span></div>
159
+ <p class="note" style="margin:.6rem 0 0">Any Hugging Face repo with <code>.safetensors</code> weights whose architecture is <b>Llama-style</b> (Llama, Mistral, Qwen2, SmolLM, TinyLlama) or <b>GPT-2-style</b>. Loading reads only <code>config.json</code>, the tokenizer, and the weight <i>headers</i> — a few tens of KB, whatever the model's size. Gated or private repos will ask for a token, once.</p>
160
+ </div>
161
+
162
+ <div class="card">
163
+ <div class="lbl">⛓ Ring plan</div>
164
+ <pre id="plan" style="max-height:140px">load a model to see how it would be split</pre>
165
+ <p class="note" style="margin:.5rem 0 0">Layers are apportioned by <b>measured</b> speed — each device times a real GEMM through its own kernel. The MB figure is what that device will actually download and hold.</p>
166
+ </div>
167
+
168
+ <div class="card">
169
+ <div class="lbl">🎚 Generation</div>
170
+ <label class="slbl">Tokens <span class="sval" id="vntok">60</span></label>
171
+ <input type="range" id="ntok" min="10" max="400" step="10" value="60">
172
+ <label class="slbl">Context length <span class="sval" id="vctxLen">64</span></label>
173
+ <input type="range" id="ctxLen" min="16" max="512" step="16" value="64">
174
+ <p class="note" style="margin:.3rem 0 .6rem">There is no KV cache — every token re-runs the whole window, so context length is the main cost per token.</p>
175
+ <label class="slbl">Temperature ÷100 <span class="sval" id="vtemp">0</span></label>
176
+ <input type="range" id="temp" min="0" max="150" step="5" value="0">
177
+ <p class="note" style="margin:.3rem 0 .6rem">0 = greedy, which is deterministic and therefore checkable. Above 0, sampling follows the seed so runs stay reproducible.</p>
178
+ <label class="slbl">Seed</label>
179
+ <input type="number" id="seed" value="1234" style="width:140px">
180
+ <div class="row" id="verifyRow" style="display:none;margin-top:10px">
181
+ <label class="note" style="display:flex;gap:8px;align-items:center;cursor:pointer">
182
+ <input type="checkbox" id="verify"> Verify afterwards against a single-device run
183
+ <span>(downloads the whole model here — only sensible for small ones)</span>
184
+ </label>
185
+ </div>
186
+ </div>
187
+
188
+ <div class="card">
189
+ <div class="lbl">✎ Prompt</div>
190
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
191
+ <input type="text" id="prompt" value="The quick brown fox" spellcheck="false" style="flex:1;min-width:180px">
192
+ <button id="genBtn" disabled>Generate</button>
193
+ <button id="stopBtn" disabled class="danger">Stop</button>
194
+ </div>
195
+ <div id="out" style="margin-top:10px"></div>
196
+ <div style="display:flex;gap:8px;margin-top:10px">
197
+ <div class="panel"><div class="num" id="tps">—</div><div class="cl">tokens / sec</div></div>
198
+ <div class="panel"><div class="num" id="tokcount">0</div><div class="cl">tokens</div></div>
199
+ <div class="panel"><div class="num" id="hops">0</div><div class="cl">ring hops</div></div>
200
+ </div>
201
+ <div class="row" style="margin-top:8px"><span class="k">Ring</span><span class="v" id="ringState">idle</span></div>
202
+ </div>
203
+
204
+ <div class="card">
205
+ <div class="lbl">❋ Log</div>
206
+ <pre id="log"></pre>
207
+ </div>
208
+
209
+ <p class="note">Needs a secure context (localhost or HTTPS) for WebGPU and cross-device WebRTC. No WebGPU? The identical verified INT8 units run on CPU — slower, but a CPU device and a GPU device produce the same bits, so they can share a ring. There is no plain-float path.</p>
210
+ <p class="note"><b>Heads up:</b> peers connect directly, so devices in your ring can see each other's IP address. A middle stage never receives the embedding table or the vocabulary, but it does see the hidden states passing through — and the head sees your prompt and output. Run rings with devices and people you trust. Proof of concept, not a hardened service.</p>
211
+
212
+ <!-- One-time credential prompt, shown only when a request actually fails for
213
+ want of one. Never stored: not localStorage, not sessionStorage, not a
214
+ cookie, and never sent to a peer — each device authenticates itself.
215
+ Reloading the tab forgets it.
216
+
217
+ On a hosted deployment the paste box is hidden entirely and only
218
+ "Sign in with Hugging Face" is offered: typing a personal access token
219
+ into a page you do not control is a bad habit even when the code is
220
+ honest, because you cannot verify that it is. -->
221
+ <div class="modal" id="tokModal">
222
+ <div class="box">
223
+ <h3>❄ Hugging Face access</h3>
224
+ <p class="note" id="tokWhy">This model needs a token to download.</p>
225
+ <div id="tokSignIn" style="display:none;margin:14px 0 6px">
226
+ <button id="tokSignInBtn" style="width:100%">Sign in with Hugging Face</button>
227
+ <p class="note" style="margin:.6rem 0 0">You are redirected to huggingface.co and back. This page receives a <b>scoped, expiring</b> token — read access to repositories, nothing else — held in memory for this tab only. No token is ever typed into this page, and none is sent to other devices.</p>
228
+ </div>
229
+ <div id="tokPasteRow">
230
+ <input type="password" id="tokInput" placeholder="hf_…" spellcheck="false" autocomplete="off">
231
+ <p class="note" style="margin:0">Kept <b>in memory for this tab only</b>: never written to disk or storage, never put in a URL, never logged, and never sent to other devices — each device is asked for its own. Create a <b>read</b> token at huggingface.co/settings/tokens.</p>
232
+ </div>
233
+ <div class="acts">
234
+ <button id="tokCancel" class="small danger">Cancel</button>
235
+ <button id="tokOk" class="small">Use token</button>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <script src="traincore.js"></script>
241
+ <script src="verified_core.js"></script>
242
+ <script src="safetensors.js"></script>
243
+ <script src="hf.js"></script>
244
+ <script src="arch.js"></script>
245
+ <script src="tokenizer.js"></script>
246
+ <script src="shard.js"></script>
247
+ <script src="wire.js"></script>
248
+ <script src="infer.js"></script>
249
+ <script src="webgpu.js"></script>
250
+ <script src="app.js"></script>
251
+ </body>
252
+ </html>
public/infer.js ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Forward-only transformer, split at layer boundaries, for real HF models.
2
+ //
3
+ // Every matrix product goes through the verified INT8 units — the same
4
+ // block-scaled quantize -> exact LUT/DP4A multiply -> exact int32 accumulate ->
5
+ // pinned f32 epilogue as the rest of DaisyChain. Nothing here computes in
6
+ // plain float except the elementwise parts that have no matmul in them
7
+ // (norms, softmax, RoPE, the activation), exactly as the trainer does it.
8
+ //
9
+ // The pass is cut into three callable pieces so they can run on different
10
+ // machines:
11
+ //
12
+ // embed(ids) head stage: token (+ learned position) embedding
13
+ // runLayers(x) any stage: its own contiguous layers
14
+ // readout(x) head stage: final norm + lm_head -> logits
15
+ //
16
+ // Architecture differences live in the spec (see arch.js), never in branches
17
+ // scattered through the maths: `spec.norm`, `spec.gated`, `spec.rope`,
18
+ // `spec.kvHeads`. A Llama block and a GPT-2 block take the same path here.
19
+ (function (root) {
20
+ "use strict";
21
+
22
+ let TC, V, SH;
23
+
24
+ // ---- norms ------------------------------------------------------------------
25
+ // RMSNorm (Llama): x * w / sqrt(mean(x^2) + eps) — no mean subtraction.
26
+ // LayerNorm (GPT-2): (x - mu) / sigma * w + b.
27
+ // Both are elementwise after a row reduction, so there is no matmul to send
28
+ // through the units, and both run in f64-accumulated JS — which IEEE requires
29
+ // to be exactly rounded, so every device agrees.
30
+ function rmsNorm(x, rows, C, w, eps) {
31
+ const y = new Float32Array(rows * C);
32
+ for (let r = 0; r < rows; r++) {
33
+ let s = 0;
34
+ for (let j = 0; j < C; j++) { const v = x[r * C + j]; s += v * v; }
35
+ const inv = 1 / Math.sqrt(s / C + eps);
36
+ for (let j = 0; j < C; j++) y[r * C + j] = x[r * C + j] * inv * w[j];
37
+ }
38
+ return y;
39
+ }
40
+ function layerNorm(x, rows, C, w, b, eps) {
41
+ const y = new Float32Array(rows * C);
42
+ for (let r = 0; r < rows; r++) {
43
+ let mu = 0;
44
+ for (let j = 0; j < C; j++) mu += x[r * C + j];
45
+ mu /= C;
46
+ let v = 0;
47
+ for (let j = 0; j < C; j++) { const d = x[r * C + j] - mu; v += d * d; }
48
+ const inv = 1 / Math.sqrt(v / C + eps);
49
+ for (let j = 0; j < C; j++) y[r * C + j] = (x[r * C + j] - mu) * inv * w[j] + (b ? b[j] : 0);
50
+ }
51
+ return y;
52
+ }
53
+ function norm(spec, x, rows, C, w, b) {
54
+ return spec.norm === "rms" ? rmsNorm(x, rows, C, w, spec.normEps)
55
+ : layerNorm(x, rows, C, w, b, spec.normEps);
56
+ }
57
+
58
+ // ---- activations ------------------------------------------------------------
59
+ const silu = (v) => v / (1 + Math.exp(-v));
60
+ // tanh-approximate GELU: what GPT-2 was trained with, so it is the correct
61
+ // function here rather than an approximation of the erf form.
62
+ function gelu(v) {
63
+ return 0.5 * v * (1 + Math.tanh(0.7978845608028654 * (v + 0.044715 * v * v * v)));
64
+ }
65
+
66
+ // ---- RoPE -------------------------------------------------------------------
67
+ // Rotary embeddings applied per head, in the half-split layout HF uses:
68
+ // dims [0, hd/2) pair with [hd/2, hd). Positions are absolute, and because
69
+ // there is no KV cache every token re-runs the whole window, so position i
70
+ // is simply the row index.
71
+ function ropeTables(hd, theta, maxT) {
72
+ const half = hd >> 1;
73
+ const cos = new Float32Array(maxT * half), sin = new Float32Array(maxT * half);
74
+ for (let p = 0; p < maxT; p++)
75
+ for (let i = 0; i < half; i++) {
76
+ const f = p / Math.pow(theta, (2 * i) / hd);
77
+ cos[p * half + i] = Math.cos(f);
78
+ sin[p * half + i] = Math.sin(f);
79
+ }
80
+ return { cos, sin, half };
81
+ }
82
+ // x is rows x (nHeads*hd), rows = T; rotate each head in place.
83
+ function applyRope(x, T, nHeads, hd, rope, posOffset) {
84
+ const half = rope.half, stride = nHeads * hd;
85
+ for (let t = 0; t < T; t++) {
86
+ const p = (posOffset || 0) + t;
87
+ for (let h = 0; h < nHeads; h++) {
88
+ const o = t * stride + h * hd;
89
+ for (let i = 0; i < half; i++) {
90
+ const c = rope.cos[p * half + i], s = rope.sin[p * half + i];
91
+ const a = x[o + i], b = x[o + half + i];
92
+ x[o + i] = a * c - b * s;
93
+ x[o + half + i] = b * c + a * s;
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ // ---- verified matmul --------------------------------------------------------
100
+ // X is m x k, W is k x n (row-major). Weights are stored k x n at load time
101
+ // (see loadStage), so nothing is transposed per call.
102
+ async function vmm(X, W, m, k, n, ctx, bias) {
103
+ const out = await V.vgemmBlock(X, W, { m, k, n, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
104
+ if (bias) for (let i = 0; i < m; i++) for (let j = 0; j < n; j++) out[i * n + j] += bias[j];
105
+ return out;
106
+ }
107
+
108
+ // ---- one layer --------------------------------------------------------------
109
+ async function layerForward(S, x, ly) {
110
+ const { spec, ctx } = S, C = spec.hidden, T = S.T;
111
+ const hd = spec.headDim, nH = spec.heads, nKV = spec.kvHeads;
112
+ const qDim = nH * hd, kvDim = nKV * hd;
113
+
114
+ // ---- attention
115
+ const h1 = norm(spec, x, T, C, ly.nrm1, ly.nrm1b);
116
+ let q, k, v;
117
+ if (spec.qkvFused) {
118
+ // GPT-2 packs q,k,v into one (C x 3C) projection — one GEMM, then split.
119
+ const qkv = await vmm(h1, ly.Wqkv, T, C, 3 * C, ctx, ly.bqkv);
120
+ q = new Float32Array(T * C); k = new Float32Array(T * C); v = new Float32Array(T * C);
121
+ for (let t = 0; t < T; t++) {
122
+ q.set(qkv.subarray(t * 3 * C, t * 3 * C + C), t * C);
123
+ k.set(qkv.subarray(t * 3 * C + C, t * 3 * C + 2 * C), t * C);
124
+ v.set(qkv.subarray(t * 3 * C + 2 * C, t * 3 * C + 3 * C), t * C);
125
+ }
126
+ } else {
127
+ [q, k, v] = await Promise.all([
128
+ vmm(h1, ly.Wq, T, C, qDim, ctx, ly.bq),
129
+ vmm(h1, ly.Wk, T, C, kvDim, ctx, ly.bk),
130
+ vmm(h1, ly.Wv, T, C, kvDim, ctx, ly.bv),
131
+ ]);
132
+ }
133
+ if (spec.rope) { applyRope(q, T, nH, hd, S.rope, 0); applyRope(k, T, nKV, hd, S.rope, 0); }
134
+
135
+ // Grouped-query attention: several query heads share one kv head. Rather
136
+ // than materialising repeated kv (memory this project does not have to
137
+ // spare), the head index is mapped when reading.
138
+ const group = nH / nKV;
139
+ const scale = 1 / Math.sqrt(hd);
140
+ const ctxOut = new Float32Array(T * qDim);
141
+ // Scores and context are the two attention GEMMs. They are small per head
142
+ // (T x hd and T x T), and the fused kernels in webgpu.js expect the
143
+ // trainer's uniform-head layout, so at general head geometry they are run
144
+ // through the same verified block GEMM per head — every product still goes
145
+ // through the units, and the CPU mirror and GPU kernel agree bit-for-bit.
146
+ for (let h = 0; h < nH; h++) {
147
+ const kvh = Math.floor(h / group);
148
+ const qh = new Float32Array(T * hd), kh = new Float32Array(T * hd), vh = new Float32Array(T * hd);
149
+ for (let t = 0; t < T; t++) {
150
+ qh.set(q.subarray(t * qDim + h * hd, t * qDim + h * hd + hd), t * hd);
151
+ kh.set(k.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
152
+ vh.set(v.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
153
+ }
154
+ const khT = TC.transpose(kh, T, hd); // hd x T
155
+ const scores = await V.vgemmBlock(qh, khT, { m: T, k: hd, n: T, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
156
+ const a = new Float32Array(T * T); // causal softmax
157
+ for (let i = 0; i < T; i++) {
158
+ let mx = -Infinity;
159
+ for (let j = 0; j <= i; j++) mx = Math.max(mx, scores[i * T + j] * scale);
160
+ let z = 0;
161
+ for (let j = 0; j <= i; j++) { const e = Math.exp(scores[i * T + j] * scale - mx); a[i * T + j] = e; z += e; }
162
+ for (let j = 0; j <= i; j++) a[i * T + j] /= z;
163
+ }
164
+ const oh = await V.vgemmBlock(a, vh, { m: T, k: T, n: hd, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
165
+ for (let t = 0; t < T; t++) ctxOut.set(oh.subarray(t * hd, t * hd + hd), t * qDim + h * hd);
166
+ }
167
+ const attn = await vmm(ctxOut, ly.Wo, T, qDim, C, ctx, ly.bo);
168
+ const x2 = new Float32Array(T * C);
169
+ for (let i = 0; i < x2.length; i++) x2[i] = x[i] + attn[i];
170
+
171
+ // ---- MLP
172
+ const h2 = norm(spec, x2, T, C, ly.nrm2, ly.nrm2b);
173
+ let hid;
174
+ if (spec.gated) {
175
+ // SwiGLU: down(silu(gate(x)) * up(x))
176
+ const [g, u] = await Promise.all([
177
+ vmm(h2, ly.Wgate, T, C, spec.inter, ctx, null),
178
+ vmm(h2, ly.Wup, T, C, spec.inter, ctx, null),
179
+ ]);
180
+ hid = g;
181
+ for (let i = 0; i < hid.length; i++) hid[i] = silu(hid[i]) * u[i];
182
+ } else {
183
+ hid = await vmm(h2, ly.Wfc, T, C, spec.inter, ctx, ly.bfc);
184
+ for (let i = 0; i < hid.length; i++) hid[i] = gelu(hid[i]);
185
+ }
186
+ const down = await vmm(hid, ly.Wdown, T, spec.inter, C, ctx, ly.bdown);
187
+ const out = new Float32Array(T * C);
188
+ for (let i = 0; i < out.length; i++) out[i] = x2[i] + down[i];
189
+ return out;
190
+ }
191
+
192
+ // ---- stage ------------------------------------------------------------------
193
+ function makeStage(spec, st, w, ctx, T) {
194
+ const S = { spec, st, w, ctx, T: T || Math.min(spec.maxPos, 128) };
195
+ if (spec.rope) S.rope = ropeTables(spec.headDim, spec.ropeTheta, S.T + 1);
196
+ return S;
197
+ }
198
+
199
+ function embed(S, ids) {
200
+ const { spec } = S, C = spec.hidden, T = S.T;
201
+ const x = new Float32Array(T * C);
202
+ for (let i = 0; i < T; i++) {
203
+ const id = ids[i];
204
+ for (let j = 0; j < C; j++) x[i * C + j] = S.w.emb[id * C + j];
205
+ }
206
+ if (S.w.pos) // GPT-2 learned positions
207
+ for (let i = 0; i < T; i++)
208
+ for (let j = 0; j < C; j++) x[i * C + j] += S.w.pos[i * C + j];
209
+ return x;
210
+ }
211
+
212
+ async function runLayers(S, x) {
213
+ for (const ly of S.w.layers) x = await layerForward(S, x, ly);
214
+ return x;
215
+ }
216
+
217
+ // Only the last position's logits are needed to pick the next token, and at
218
+ // a 150k-token vocabulary that turns the largest GEMM in the model into a
219
+ // single row — the biggest single saving in the ring.
220
+ async function readout(S, x) {
221
+ const { spec, ctx } = S, C = spec.hidden, T = S.T;
222
+ const y = norm(spec, x, T, C, S.w.nrmF, S.w.nrmFb);
223
+ const last = y.subarray((T - 1) * C, T * C);
224
+ return vmm(last, S.w.lmHead, 1, C, spec.vocab, ctx, null);
225
+ }
226
+
227
+ // ---- sampling ---------------------------------------------------------------
228
+ function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
229
+ function pickToken(logits, opts) {
230
+ const o = opts || {}, temp = o.temperature ?? 0, vocab = logits.length;
231
+ if (!temp) {
232
+ let best = 0, bv = -Infinity;
233
+ for (let j = 0; j < vocab; j++) if (logits[j] > bv) { bv = logits[j]; best = j; }
234
+ return best;
235
+ }
236
+ const k = Math.min(o.topK || 40, vocab);
237
+ const idx = Array.from({ length: vocab }, (_, i) => i).sort((a, b) => logits[b] - logits[a]).slice(0, k);
238
+ let mx = -Infinity;
239
+ for (const i of idx) mx = Math.max(mx, logits[i] / temp);
240
+ let z = 0;
241
+ const p = idx.map(i => { const e = Math.exp(logits[i] / temp - mx); z += e; return e; });
242
+ let r = (o.rng || Math.random)() * z;
243
+ for (let i = 0; i < idx.length; i++) { r -= p[i]; if (r <= 0) return idx[i]; }
244
+ return idx[idx.length - 1];
245
+ }
246
+
247
+ // ---- single-device reference ------------------------------------------------
248
+ async function generateLocal(stages, ids, nTokens, opts) {
249
+ const head = stages[0], T = head.T;
250
+ const out = [...ids];
251
+ for (let n = 0; n < nTokens; n++) {
252
+ const win = new Int32Array(T);
253
+ const tail = out.slice(-T);
254
+ for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
255
+ let x = embed(head, win);
256
+ for (const S of stages) x = await runLayers(S, x);
257
+ out.push(pickToken(await readout(head, x), opts));
258
+ }
259
+ return out;
260
+ }
261
+
262
+ const api = { makeStage, embed, runLayers, readout, pickToken, generateLocal,
263
+ rmsNorm, layerNorm, norm, ropeTables, applyRope, silu, gelu, mulberry32 };
264
+ if (typeof module !== "undefined" && module.exports) {
265
+ TC = require("./traincore.js"); V = require("./verified_core.js"); SH = require("./shard.js");
266
+ module.exports = api;
267
+ } else { TC = root.TrainCore; V = root.Verified; SH = root.Shard; root.Infer = api; }
268
+ })(typeof self !== "undefined" ? self : this);
public/luts_meta.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"mul": [256, 256], "requant": 65536, "relu": 256, "shift": 8}
public/mul_lut.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5cecff7e22049d0083ad9ee36dcf0695222c61621bacfd5a401c7b133abe892d
3
+ size 131072
public/relu_lut.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2acb03ba7520467636273208563f8e733494748f4aa5ac2dba89d9560050da79
3
+ size 256
public/requant_lut.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:173444ecfa293433329a333289983a665c481d913e9fd1c2778b55380ca4dd31
3
+ size 65536
public/safetensors.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // safetensors, read a piece at a time.
2
+ //
3
+ // The format is what makes this project's memory pooling honest. A file is:
4
+ //
5
+ // [u64 headerLen][JSON header][ tensor bytes, back to back ]
6
+ //
7
+ // and the header gives every tensor's exact byte range. So a device does not
8
+ // need the model — it needs its own layers, and it can ask the CDN for exactly
9
+ // those bytes. Nothing else is ever downloaded, decoded, or held.
10
+ //
11
+ // That is the difference between "we split the model across devices" and
12
+ // "one device downloads the model and hands out pieces". Only the first one
13
+ // actually lets a group run something none of them could hold.
14
+ (function (root) {
15
+ "use strict";
16
+
17
+ // ---- dtype decoding ---------------------------------------------------------
18
+ // Everything becomes f32, because that is what the verified units quantize
19
+ // from. F16 and BF16 are the common storage formats and both widen exactly —
20
+ // no rounding, no device-dependent behaviour, so a CPU device and a GPU
21
+ // device still agree bit-for-bit after loading.
22
+ const BYTES = { F64: 8, F32: 4, F16: 2, BF16: 2, I64: 8, I32: 4, I16: 2, I8: 1, U8: 1, BOOL: 1 };
23
+
24
+ // BF16 is the top 16 bits of an f32 — widening is a shift, exactly.
25
+ function bf16ToF32(u16, out) {
26
+ const u32 = new Uint32Array(out.buffer, out.byteOffset, out.length);
27
+ for (let i = 0; i < u16.length; i++) u32[i] = u16[i] << 16;
28
+ return out;
29
+ }
30
+ // F16 -> F32 by hand rather than via Float16Array, which is not everywhere
31
+ // yet. Subnormals and inf/nan are handled explicitly; every f16 has an exact
32
+ // f32 representation, so this is a widening, not a conversion.
33
+ function f16ToF32(u16, out) {
34
+ for (let i = 0; i < u16.length; i++) {
35
+ const h = u16[i], s = (h & 0x8000) >> 15, e = (h & 0x7C00) >> 10, f = h & 0x03FF;
36
+ let v;
37
+ if (e === 0) v = f === 0 ? 0 : Math.pow(2, -14) * (f / 1024);
38
+ else if (e === 0x1F) v = f === 0 ? Infinity : NaN;
39
+ else v = Math.pow(2, e - 15) * (1 + f / 1024);
40
+ out[i] = s ? -v : v;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function toF32(dtype, bytes) {
46
+ const n = bytes.byteLength / BYTES[dtype];
47
+ if (dtype === "F32") {
48
+ // may be unaligned inside a range response — copy rather than view
49
+ const out = new Float32Array(n);
50
+ new Uint8Array(out.buffer).set(bytes);
51
+ return out;
52
+ }
53
+ const out = new Float32Array(n);
54
+ if (dtype === "F16" || dtype === "BF16") {
55
+ const u16 = new Uint16Array(n);
56
+ new Uint8Array(u16.buffer).set(bytes);
57
+ return dtype === "BF16" ? bf16ToF32(u16, out) : f16ToF32(u16, out);
58
+ }
59
+ if (dtype === "F64") {
60
+ const f64 = new Float64Array(n);
61
+ new Uint8Array(f64.buffer).set(bytes);
62
+ for (let i = 0; i < n; i++) out[i] = f64[i];
63
+ return out;
64
+ }
65
+ throw new Error(`unsupported tensor dtype ${dtype} — weights must be F32, F16, BF16 or F64`);
66
+ }
67
+
68
+ // ---- header ----------------------------------------------------------------
69
+ // The first 8 bytes are a u64 length. It is read as two u32s because a JS
70
+ // number cannot hold a u64 — but a header that genuinely needed the high word
71
+ // would be gigabytes, so a nonzero high word means the file is not what it
72
+ // claims and is refused rather than truncated into something plausible.
73
+ function parseHeader(buf) {
74
+ const dv = new DataView(buf);
75
+ const lo = dv.getUint32(0, true), hi = dv.getUint32(4, true);
76
+ if (hi !== 0) throw new Error("safetensors header length is implausibly large — not a safetensors file");
77
+ if (lo <= 0 || lo > buf.byteLength - 8) throw new Error("safetensors header is truncated");
78
+ let json;
79
+ try { json = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, lo))); }
80
+ catch (e) { throw new Error("safetensors header is not valid JSON — not a safetensors file"); }
81
+ const dataStart = 8 + lo;
82
+ const tensors = new Map();
83
+ for (const [name, t] of Object.entries(json)) {
84
+ if (name === "__metadata__") continue;
85
+ if (!t || !t.dtype || !Array.isArray(t.shape) || !Array.isArray(t.data_offsets))
86
+ throw new Error(`safetensors header entry "${name}" is malformed`);
87
+ const [s, e] = t.data_offsets;
88
+ const elems = t.shape.reduce((a, b) => a * b, 1);
89
+ const want = elems * (BYTES[t.dtype] || 0);
90
+ if (BYTES[t.dtype] && e - s !== want)
91
+ throw new Error(`tensor "${name}": header claims ${e - s} bytes for a ${t.shape.join("x")} ${t.dtype} (expected ${want})`);
92
+ tensors.set(name, { name, dtype: t.dtype, shape: t.shape, elems,
93
+ start: dataStart + s, end: dataStart + e, bytes: e - s });
94
+ }
95
+ return { tensors, dataStart, metadata: json.__metadata__ || {} };
96
+ }
97
+
98
+ // How much a set of tensors will cost to hold, before fetching any of it.
99
+ // Reported in the UI so a device can see whether its slice fits BEFORE it
100
+ // spends the bandwidth finding out.
101
+ function f32Bytes(tensors) {
102
+ let n = 0;
103
+ for (const t of tensors) n += t.elems * 4;
104
+ return n;
105
+ }
106
+
107
+ // ---- coalescing -------------------------------------------------------------
108
+ // A stage's tensors are contiguous in the file far more often than not
109
+ // (safetensors writes them in the order they were registered, which follows
110
+ // layer order). Merging adjacent ranges turns ~9 requests per layer into
111
+ // roughly one per stage. Gaps below `slack` are downloaded and discarded
112
+ // because one extra request costs more than a few unused kilobytes.
113
+ function coalesce(tensors, slack) {
114
+ const gap = slack == null ? 1 << 20 : slack;
115
+ const sorted = [...tensors].sort((a, b) => a.start - b.start);
116
+ const runs = [];
117
+ for (const t of sorted) {
118
+ const last = runs[runs.length - 1];
119
+ if (last && t.start - last.end <= gap) { last.end = Math.max(last.end, t.end); last.tensors.push(t); }
120
+ else runs.push({ start: t.start, end: t.end, tensors: [t] });
121
+ }
122
+ return runs;
123
+ }
124
+
125
+ const api = { BYTES, parseHeader, toF32, coalesce, f32Bytes, f16ToF32, bf16ToF32 };
126
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
127
+ else root.Safetensors = api;
128
+ })(typeof self !== "undefined" ? self : this);
public/shard.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Model sharding — the piece DaisyChain-Train does not have.
2
+ //
3
+ // DaisyChain-Train pools COMPUTE: every node holds a full replica, so a model
4
+ // bigger than one machine cannot be trained. Inference is where that limit can
5
+ // be lifted, because a forward pass is a chain: layer l needs layer l-1's
6
+ // OUTPUT, never its WEIGHTS. So the layers live on different machines and the
7
+ // activation travels instead.
8
+ //
9
+ // And because safetensors gives every tensor an exact byte range, each device
10
+ // fetches only its own layers straight from the Hub. No device — not even the
11
+ // head — ever holds the whole model. That is what makes the pooling real
12
+ // rather than a redistribution of something one machine already had to load.
13
+ //
14
+ // The ring shape is forced by weight tying. When lm_head is tied to the
15
+ // embedding table, the largest tensor in the model is needed at BOTH ends: to
16
+ // embed the prompt and to produce the logits. Copying it to the last stage
17
+ // would hand back most of the memory just pooled, so the last stage returns
18
+ // its hidden state to the head, which owns the embedding once and does both
19
+ // ends of the pass.
20
+ (function (root) {
21
+ "use strict";
22
+
23
+ // ---- the plan ---------------------------------------------------------------
24
+ // Stage 0 is the HEAD: embeddings, final norm, lm_head. Every stage may also
25
+ // own a contiguous run of layers, apportioned by MEASURED capacity — the
26
+ // same self-calibrating idea as cluster.py's capacity_score, except what is
27
+ // balanced is layers per device rather than batch per device.
28
+ //
29
+ // Largest-remainder apportionment, so the plan is a pure function of the
30
+ // capacity report: every device derives the same plan from the same inputs
31
+ // and the plan never has to be trusted, only compared.
32
+ function planStages(spec, caps) {
33
+ const n = caps.length;
34
+ if (n < 1) throw new Error("no devices to plan across");
35
+ const total = caps.reduce((a, d) => a + Math.max(1e-6, d.capacity), 0);
36
+ const exact = caps.map(d => spec.layers * Math.max(1e-6, d.capacity) / total);
37
+ const floor = exact.map(Math.floor);
38
+ let left = spec.layers - floor.reduce((a, b) => a + b, 0);
39
+ const order = exact.map((e, i) => [e - floor[i], i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
40
+ for (let i = 0; i < order.length && left > 0; i++, left--) floor[order[i][1]]++;
41
+ const stages = [];
42
+ let lo = 0;
43
+ for (let i = 0; i < n; i++) {
44
+ const hi = lo + floor[i];
45
+ stages.push({ id: caps[i].id, index: i, lo, hi, head: i === 0,
46
+ backend: caps[i].backend, capacity: caps[i].capacity });
47
+ lo = hi;
48
+ }
49
+ // A device with no layers is only worth a hop if it is the head, which has
50
+ // real work (embed + unembed). Anyone else empty is dropped.
51
+ return stages.filter(s => s.head || s.hi > s.lo).map((s, i) => ({ ...s, index: i }));
52
+ }
53
+
54
+ // What a stage will cost to hold, computed from the file's own header BEFORE
55
+ // any weight bytes move — so a device can see whether its slice fits before
56
+ // spending the bandwidth finding out.
57
+ function stageBytes(spec, available, st, ArchMod) {
58
+ const names = ArchMod.tensorsFor(spec, available, st);
59
+ let n = 0;
60
+ for (const name of names) n += available.get(name).elems * 4; // f32 in memory
61
+ return n;
62
+ }
63
+
64
+ // ---- arranging fetched tensors into what the forward pass wants -------------
65
+ // Two shape conventions have to be reconciled here, and getting it wrong is
66
+ // silent: torch.nn.Linear stores weights (out, in) while the GEMM wants
67
+ // (in, out), whereas GPT-2's Conv1D already stores (in, out). A wrong
68
+ // transpose does not throw — it produces a model that generates confident
69
+ // nonsense — so the layout comes from the spec and is applied once, at load.
70
+ function toKN(w, outDim, inDim, layout) {
71
+ if (layout === "in_out") return w; // already k x n
72
+ const out = new Float32Array(w.length);
73
+ for (let o = 0; o < outDim; o++)
74
+ for (let i = 0; i < inDim; i++) out[i * outDim + o] = w[o * inDim + i];
75
+ return out;
76
+ }
77
+
78
+ function stageWeights(spec, st, got, ArchMod) {
79
+ const resolve = ArchMod.resolver(new Map([...got.keys()].map(k => [k, true])));
80
+ const pick = (name, opt) => { const r = resolve(name, opt); return r ? got.get(r) : null; };
81
+ const C = spec.hidden, L = spec.weightLayout;
82
+ const w = { layers: [] };
83
+
84
+ if (st.head) {
85
+ const h = ArchMod.headTensors(spec);
86
+ w.emb = pick(h.emb); // (vocab, C) — used as a lookup, not a GEMM
87
+ w.pos = pick(h.pos, true);
88
+ w.nrmF = pick(h.nrmF, true);
89
+ w.nrmFb = pick(h.nrmFb, true);
90
+ // lm_head as k x n = (C, vocab). When tied, that is embᵀ.
91
+ const lm = pick(h.lmHead, true);
92
+ w.lmHead = lm ? toKN(lm, spec.vocab, C, L) : transposeEmb(w.emb, spec.vocab, C);
93
+ if (!w.nrmF) w.nrmF = new Float32Array(C).fill(1); // a model without a final norm weight
94
+ }
95
+
96
+ const qDim = spec.heads * spec.headDim, kvDim = spec.kvHeads * spec.headDim;
97
+ for (let l = st.lo; l < st.hi; l++) {
98
+ const t = ArchMod.layerTensors(spec, l);
99
+ const ly = { nrm1: pick(t.nrm1), nrm1b: pick(t.nrm1b, true),
100
+ nrm2: pick(t.nrm2), nrm2b: pick(t.nrm2b, true) };
101
+ if (spec.qkvFused) {
102
+ ly.Wqkv = toKN(pick(t.Wqkv), 3 * C, C, L);
103
+ ly.bqkv = pick(t.bqkv, true);
104
+ } else {
105
+ ly.Wq = toKN(pick(t.Wq), qDim, C, L); ly.bq = pick(t.bq, true);
106
+ ly.Wk = toKN(pick(t.Wk), kvDim, C, L); ly.bk = pick(t.bk, true);
107
+ ly.Wv = toKN(pick(t.Wv), kvDim, C, L); ly.bv = pick(t.bv, true);
108
+ }
109
+ ly.Wo = toKN(pick(t.Wo), C, qDim, L); ly.bo = pick(t.bo, true);
110
+ if (spec.gated) {
111
+ ly.Wgate = toKN(pick(t.Wgate), spec.inter, C, L);
112
+ ly.Wup = toKN(pick(t.Wup), spec.inter, C, L);
113
+ } else {
114
+ ly.Wfc = toKN(pick(t.Wfc), spec.inter, C, L); ly.bfc = pick(t.bfc, true);
115
+ }
116
+ ly.Wdown = toKN(pick(t.Wdown), C, spec.inter, L); ly.bdown = pick(t.bdown, true);
117
+ w.layers.push(ly);
118
+ }
119
+ return w;
120
+ }
121
+ function transposeEmb(emb, vocab, C) {
122
+ const out = new Float32Array(emb.length);
123
+ for (let v = 0; v < vocab; v++)
124
+ for (let c = 0; c < C; c++) out[c * vocab + v] = emb[v * C + c];
125
+ return out;
126
+ }
127
+
128
+ // ---- hashes -----------------------------------------------------------------
129
+ // FNV-1a over raw bytes, identical to the function DaisyChain-Web hashes
130
+ // replicas with, so hashes stay comparable across the projects.
131
+ function fnv1a(bytes) {
132
+ let h = 0x811c9dc5;
133
+ for (let i = 0; i < bytes.length; i++) { h ^= bytes[i]; h = Math.imul(h, 0x01000193); }
134
+ return h >>> 0;
135
+ }
136
+ function hashF32(a) { return fnv1a(new Uint8Array(a.buffer, a.byteOffset, a.byteLength)); }
137
+
138
+ // The model fingerprint every stage repeats in its status. Derived from the
139
+ // repo id, revision and the tensor index — NOT from the weights, because no
140
+ // device reads all of them. It answers "are we all running the same model?",
141
+ // which is the question that matters when stages hold disjoint pieces.
142
+ function modelFingerprint(repo, revision, tensors) {
143
+ const parts = [repo, revision || "main"];
144
+ for (const name of [...tensors.keys()].sort()) {
145
+ const t = tensors.get(name);
146
+ parts.push(`${name}:${t.dtype}:${t.shape.join("x")}:${t.start}:${t.end}`);
147
+ }
148
+ return fnv1a(new TextEncoder().encode(parts.join("|")));
149
+ }
150
+
151
+ const api = { planStages, stageBytes, stageWeights, toKN, transposeEmb,
152
+ fnv1a, hashF32, modelFingerprint };
153
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
154
+ else root.Shard = api;
155
+ })(typeof self !== "undefined" ? self : this);
public/tokenizer.js ADDED
Binary file (5.7 kB). View file
 
public/traincore.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared training math — pure JS. Used as the WebGPU fallback in the browser,
2
+ // and unit-tested directly in Node. Model: linear regression Y = X @ W (MSE).
3
+ // The GEMMs here are exactly what the WebGPU compute shader replaces.
4
+ (function (root) {
5
+ "use strict";
6
+
7
+ // C(m×n) = A(m×k) @ B(k×n), all Float32Array row-major
8
+ function matmul(A, B, m, k, n) {
9
+ const C = new Float32Array(m * n);
10
+ for (let i = 0; i < m; i++) {
11
+ for (let p = 0; p < k; p++) {
12
+ const a = A[i * k + p];
13
+ if (a === 0) continue;
14
+ const bo = p * n, co = i * n;
15
+ for (let j = 0; j < n; j++) C[co + j] += a * B[bo + j];
16
+ }
17
+ }
18
+ return C;
19
+ }
20
+
21
+ function transpose(A, rows, cols) {
22
+ const T = new Float32Array(rows * cols);
23
+ for (let i = 0; i < rows; i++)
24
+ for (let j = 0; j < cols; j++) T[j * rows + i] = A[i * cols + j];
25
+ return T;
26
+ }
27
+
28
+ // Forward + loss + gradient for one shard.
29
+ // X: n×din, W: din×dout, y: n×dout
30
+ // gradW = (2/n) * Xᵀ @ (X@W - y) (din×dout)
31
+ // matmulFn lets the browser swap in the WebGPU GEMM (same signature as matmul).
32
+ function forwardLossGrad(X, y, W, n, din, dout, matmulFn) {
33
+ const mm = matmulFn || matmul;
34
+ const pred = mm(X, W, n, din, dout); // n×dout (GEMM)
35
+ const resid = new Float32Array(n * dout);
36
+ let loss = 0;
37
+ for (let i = 0; i < n * dout; i++) {
38
+ const r = pred[i] - y[i];
39
+ resid[i] = r; loss += r * r;
40
+ }
41
+ loss /= (n * dout);
42
+ const Xt = transpose(X, n, din); // din×n
43
+ const g = mm(Xt, resid, din, n, dout); // din×dout (GEMM)
44
+ const scale = 2 / n;
45
+ for (let i = 0; i < g.length; i++) g[i] *= scale;
46
+ return { pred, loss, gradW: g };
47
+ }
48
+
49
+ function applyGrad(W, gradAvg, lr) {
50
+ for (let i = 0; i < W.length; i++) W[i] -= lr * gradAvg[i];
51
+ }
52
+
53
+ // average a list of gradient Float32Arrays (equal weight)
54
+ // ORDER MATTERS: float addition is not associative, so every replica MUST
55
+ // pass the gradients in the same order (the leader's roster order) or their
56
+ // averages differ in the last bits and the weights fork. Never self-first.
57
+ function averageGrads(grads) {
58
+ const out = new Float32Array(grads[0].length);
59
+ for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
60
+ for (let i = 0; i < out.length; i++) out[i] /= grads.length;
61
+ return out;
62
+ }
63
+
64
+ // DaisyAdam — Adam with bias correction, applied to the cluster-averaged
65
+ // gradient. State is a pure function of the gradient sequence, so every peer
66
+ // that averages the same gradients keeps bit-identical moments: no optimizer
67
+ // state ever crosses the wire. Momentum also smooths the noisy STE gradients
68
+ // coming out of the verified INT8 units.
69
+ function makeAdam(dim, opts) {
70
+ const o = opts || {};
71
+ const lr = o.lr ?? 0.02, b1 = o.beta1 ?? 0.9, b2 = o.beta2 ?? 0.999, eps = o.eps ?? 1e-8;
72
+ const m = new Float32Array(dim), v = new Float32Array(dim);
73
+ let t = 0;
74
+ return {
75
+ name: `adam(lr=${lr})`,
76
+ // returns the update u; caller does W[i] -= u[i]
77
+ step(g) {
78
+ t++;
79
+ const c1 = 1 - Math.pow(b1, t), c2 = 1 - Math.pow(b2, t);
80
+ const u = new Float32Array(dim);
81
+ for (let i = 0; i < dim; i++) {
82
+ m[i] = b1 * m[i] + (1 - b1) * g[i];
83
+ v[i] = b2 * v[i] + (1 - b2) * g[i] * g[i];
84
+ u[i] = lr * (m[i] / c1) / (Math.sqrt(v[i] / c2) + eps);
85
+ }
86
+ return u;
87
+ },
88
+ // snapshot/restore the moments — this is what lets a device that joins
89
+ // mid-run become bit-identical with the group (weights alone are not
90
+ // enough; Adam's m/v/t must match too)
91
+ getState() { return { m: Float32Array.from(m), v: Float32Array.from(v), t }; },
92
+ setState(s) { m.set(s.m); v.set(s.v); t = s.t; },
93
+ };
94
+ }
95
+
96
+ const api = { matmul, transpose, forwardLossGrad, applyGrad, averageGrads, makeAdam };
97
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
98
+ else root.TrainCore = api;
99
+ })(typeof self !== "undefined" ? self : this);
public/verified_core.js ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Verified INT8 compute — the emulated GPU logic, in the browser.
2
+ // A layer's forward runs THROUGH the units: quantize -> LUT multiply -> requant
3
+ // -> optional ReLU -> dequant. Backward is a straight-through estimator (the
4
+ // integer path has no gradient), so ordinary float weights still learn.
5
+ // Same units as the Python/Docker DaisyChain; here they're lookup tables.
6
+ (function (root) {
7
+ "use strict";
8
+
9
+ let TC; // TrainCore (matmul/transpose) — resolved per environment at the end
10
+
11
+ function quantize(X) {
12
+ let mx = 0; for (let i = 0; i < X.length; i++) { const a = Math.abs(X[i]); if (a > mx) mx = a; }
13
+ const scale = Math.max(mx / 127, 1e-8);
14
+ const q = new Int8Array(X.length);
15
+ for (let i = 0; i < X.length; i++) { let v = Math.round(X[i] / scale); q[i] = v < -128 ? -128 : v > 127 ? 127 : v; }
16
+ return { q, scale };
17
+ }
18
+
19
+ // int8 matmul via the verified multiply LUT: acc(m×n) = sum_k mulLUT[Xq,Wq]
20
+ function lutMatmulJS(Xq, Wq, m, k, n, L) {
21
+ const C = new Int32Array(m * n), mul = L.mul;
22
+ for (let i = 0; i < m; i++) {
23
+ for (let p = 0; p < k; p++) {
24
+ const au = (Xq[i * k + p] & 0xFF) * 256, wo = p * n, co = i * n;
25
+ for (let j = 0; j < n; j++) C[co + j] += mul[au + (Wq[wo + j] & 0xFF)];
26
+ }
27
+ }
28
+ return C;
29
+ }
30
+
31
+ // ---- 3xINT8 fast-accurate GEMM --------------------------------------------
32
+ // The CUTLASS example-27 "3xTF32" scheme, ported to the verified units:
33
+ // split each float into a coarse int8 part plus an int8-quantized residual,
34
+ // run three EXACT LUT GEMMs (hi·hi, hi·lo, lo·hi), drop the negligible
35
+ // lo·lo, and recombine. Same big/small decomposition NVIDIA uses to recover
36
+ // near-fp32 accuracy from TF32 tensor cores — here it recovers ~14-bit
37
+ // accuracy from the 8-bit units, at 3× the unit ops. Every product still
38
+ // goes through the verified mul8 LUT.
39
+ function quantize2(X) {
40
+ const hi = quantize(X);
41
+ const r = new Float32Array(X.length);
42
+ for (let i = 0; i < X.length; i++) r[i] = X[i] - hi.q[i] * hi.scale;
43
+ const lo = quantize(r);
44
+ return { hi, lo };
45
+ }
46
+ function combine3(hh, hl, lh, x, w, len) {
47
+ const out = new Float32Array(len);
48
+ const shh = x.hi.scale * w.hi.scale, shl = x.hi.scale * w.lo.scale, slh = x.lo.scale * w.hi.scale;
49
+ for (let i = 0; i < len; i++) out[i] = hh[i] * shh + hl[i] * shl + lh[i] * slh;
50
+ return out;
51
+ }
52
+ function lutMatmul3JS(Xf, Wf, m, k, n, L) { // sync, CPU LUT path
53
+ const x = quantize2(Xf), w = quantize2(Wf);
54
+ return combine3(lutMatmulJS(x.hi.q, w.hi.q, m, k, n, L),
55
+ lutMatmulJS(x.hi.q, w.lo.q, m, k, n, L),
56
+ lutMatmulJS(x.lo.q, w.hi.q, m, k, n, L), x, w, m * n);
57
+ }
58
+ async function lutMatmul3(Xf, Wf, m, k, n, L, matmulInt8) { // any backend
59
+ const x = quantize2(Xf), w = quantize2(Wf);
60
+ const mm = matmulInt8 || lutMatmulJS;
61
+ const [hh, hl, lh] = await Promise.all([
62
+ mm(x.hi.q, w.hi.q, m, k, n, L),
63
+ mm(x.hi.q, w.lo.q, m, k, n, L),
64
+ mm(x.lo.q, w.hi.q, m, k, n, L),
65
+ ]);
66
+ return combine3(hh, hl, lh, x, w, m * n);
67
+ }
68
+
69
+ // ---- block-scaled verified GEMM (CUTLASS ex. 67/81 blockwise scaling) ------
70
+ // Per-ROW scales for the activations and per-COLUMN scales for the weights:
71
+ // the integer math through the mul8 LUT is completely unchanged — only the
72
+ // dequant uses rs[row]·cs[col] instead of one tensor-wide product, so a single
73
+ // outlier no longer crushes the quantization resolution of every other
74
+ // row/column. One LUT pass at this granularity beats the per-tensor 3-pass.
75
+ function quantizeRows(X, rows, cols) {
76
+ const q = new Int8Array(rows * cols), s = new Float32Array(rows);
77
+ for (let r = 0; r < rows; r++) {
78
+ let mx = 0;
79
+ for (let c = 0; c < cols; c++) { const a = Math.abs(X[r * cols + c]); if (a > mx) mx = a; }
80
+ const sc = Math.max(mx / 127, 1e-8); s[r] = sc;
81
+ for (let c = 0; c < cols; c++) {
82
+ const v = Math.round(X[r * cols + c] / sc);
83
+ q[r * cols + c] = v < -128 ? -128 : v > 127 ? 127 : v;
84
+ }
85
+ }
86
+ return { q, s };
87
+ }
88
+ function quantizeCols(W, rows, cols) {
89
+ const q = new Int8Array(rows * cols), s = new Float32Array(cols);
90
+ for (let c = 0; c < cols; c++) {
91
+ let mx = 0;
92
+ for (let r = 0; r < rows; r++) { const a = Math.abs(W[r * cols + c]); if (a > mx) mx = a; }
93
+ s[c] = Math.max(mx / 127, 1e-8);
94
+ }
95
+ for (let r = 0; r < rows; r++)
96
+ for (let c = 0; c < cols; c++) {
97
+ const v = Math.round(W[r * cols + c] / s[c]);
98
+ q[r * cols + c] = v < -128 ? -128 : v > 127 ? 127 : v;
99
+ }
100
+ return { q, s };
101
+ }
102
+
103
+ // ---- B2B MLP chain (CUTLASS ex. 13 two-GEMM fusion + ex. 23 epilogue
104
+ // reduction), respecced for cross-device exactness ---------------------------
105
+ // The MLP is the one back-to-back GEMM pair with no layernorm/softmax between
106
+ // (ReLU is already fused in the epilogue), so the intermediate h1 can be
107
+ // quantized ON the GPU and fed straight to the second GEMM. Two rules make
108
+ // that fleet-safe:
109
+ // 1. The per-row |max| (ex. 23) uses only comparisons — exact on any
110
+ // hardware, order-independent — and comes back to JS as ~1KB.
111
+ // 2. Scale DERIVATION (two divisions) stays in JS f64, which IEEE requires
112
+ // to be exactly rounded and is therefore identical on every device.
113
+ // WGSL division is only 2.5 ULP — a fork waiting to happen — but WGSL
114
+ // multiply/add are correctly rounded and floor/clamp are exact. So the
115
+ // quantize step is respecced from round(x / scale) to
116
+ // floor(f32(x * invScale) + 0.5) — floor(x+0.5) IS Math.round's tie
117
+ // rule — and the fround-stepped mirror below is bit-identical to the
118
+ // GPU kernel, which is exact-gated against it at init.
119
+ // NOTE this changes which int8 a value on a rounding boundary lands on
120
+ // (≤1 step) vs quantizeRows, so old and new builds cannot co-train — the
121
+ // divergence guard stops such mixed groups by design.
122
+ function rowAbsMax(X, rows, cols) {
123
+ const mx = new Float32Array(rows);
124
+ for (let r = 0; r < rows; r++) {
125
+ let m = 0;
126
+ for (let c = 0; c < cols; c++) { const a = Math.abs(X[r * cols + c]); if (a > m) m = a; }
127
+ mx[r] = m;
128
+ }
129
+ return mx;
130
+ }
131
+ function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
132
+ const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
133
+ for (let i = 0; i < mx.length; i++) {
134
+ scale[i] = Math.max(mx[i] / 127, 1e-8);
135
+ inv[i] = 1 / scale[i]; // recip of the STORED f32 scale
136
+ }
137
+ return { scale, inv };
138
+ }
139
+ function quantizeRowsInv(X, rows, cols, inv) { // bit-exact mirror of the GPU quantize kernel
140
+ const q = new Int8Array(rows * cols);
141
+ for (let r = 0; r < rows; r++) {
142
+ const iv = inv[r];
143
+ for (let c = 0; c < cols; c++) {
144
+ const n = Math.floor(f32(f32(X[r * cols + c] * iv) + 0.5));
145
+ q[r * cols + c] = n < -128 ? -128 : n > 127 ? 127 : n;
146
+ }
147
+ }
148
+ return q;
149
+ }
150
+ // the chained MLP: X @ W1 -> ReLU (fused) -> absmax -> quantize -> @ W2.
151
+ // d = { m, k, h, n }; gpuMlp (from webgpu.js) runs both GEMMs + the
152
+ // on-GPU quantize with one tiny absmax readback between; without it the CPU
153
+ // mirror chain runs — SAME math, so mixed GPU/CPU fleets stay bit-identical.
154
+ async function vmlpBlock(Xf, W1f, W2f, d, L, gpuMlp, audit) {
155
+ const x = quantizeRows(Xf, d.m, d.k);
156
+ const w1 = quantizeCols(W1f, d.k, d.h);
157
+ const w2 = quantizeCols(W2f, d.h, d.n);
158
+ if (gpuMlp) {
159
+ const r = await gpuMlp(x.q, w1.q, w2.q, x.s, w1.s, w2.s, d);
160
+ if (audit && audit.due()) {
161
+ // audit BOTH live GEMMs: gemm1 against the units directly; gemm2 by
162
+ // reconstructing its exact operand through the proven quantize mirror
163
+ const bad1 = auditTile(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, relu: true }, r.h1, L, audit.cells);
164
+ if (bad1) { audit.fail("mlp gemm1: " + bad1); return r; }
165
+ const sc = scalesFromAbsMax(rowAbsMax(r.h1, d.m, d.h));
166
+ const hq = quantizeRowsInv(r.h1, d.m, d.h, sc.inv);
167
+ const bad2 = auditTile(hq, w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n }, r.out, L, audit.cells);
168
+ if (bad2) audit.fail("mlp gemm2: " + bad2);
169
+ }
170
+ return r;
171
+ }
172
+ const h1 = bgemmJS(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, batch: 1, relu: true }, L);
173
+ const sc = scalesFromAbsMax(rowAbsMax(h1, d.m, d.h));
174
+ const hq = quantizeRowsInv(h1, d.m, d.h, sc.inv);
175
+ const out = bgemmJS(hq, w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n, batch: 1 }, L);
176
+ return { h1, out };
177
+ }
178
+
179
+ // ---- epilogue mirror -------------------------------------------------------
180
+ // BIT-EXACT mirror of the WGSL epilogue `f32(s) * a * b`. WGSL rounds to f32
181
+ // after the int->float conversion and after EACH multiply; plain JS would do
182
+ // the whole chain in f64 and round once, which differs in the last ulp. That
183
+ // last-ulp gap is what used to force a tolerance into the kernel gates —
184
+ // mirroring the rounding exactly is what lets the gates compare with `!==`.
185
+ const f32 = Math.fround;
186
+ function epi(s, a, b) { return f32(f32(f32(s) * a) * b); }
187
+
188
+ // f32 equality at the BIT level: `!==` says -0 === 0, but replicas are
189
+ // compared by hashing raw bytes, so an audit that can't see the sign of zero
190
+ // could pass a device that later forks the fleet. (Real ISAs have non-IEEE
191
+ // modes that flush -0 to +0 — e.g. RDNA2 output modifiers / legacy muls.)
192
+ const _fb = new Float32Array(1), _ub = new Uint32Array(_fb.buffer);
193
+ function bitDiff(a, b) { _fb[0] = a; const u = _ub[0]; _fb[0] = b; return u !== _ub[0]; }
194
+
195
+ // CPU mirror of the fused GPU kernel: batched int8 GEMM through the LUT with
196
+ // the epilogue (block dequant + optional ReLU) applied before returning —
197
+ // exactly what the WGSL kernel does on-device. d.acc=true returns the raw
198
+ // int32 accumulator instead (the exact oracle the fused kernel normally hides).
199
+ function bgemmJS(Xq, Wq, rs, cs, d, L) {
200
+ const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
201
+ const raw = !!d.acc;
202
+ const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
203
+ const acc = new Int32Array(n);
204
+ for (let bz = 0; bz < batch; bz++) {
205
+ const xo = bz * m * k, wo = bz * k * n, oo = bz * m * n, co = bz * n;
206
+ for (let i = 0; i < m; i++) {
207
+ acc.fill(0);
208
+ const xrow = xo + i * k;
209
+ for (let p = 0; p < k; p++) {
210
+ const au = (Xq[xrow + p] & 0xFF) * 256, wrow = wo + p * n;
211
+ for (let j = 0; j < n; j++) acc[j] += mul[au + (Wq[wrow + j] & 0xFF)];
212
+ }
213
+ const orow = oo + i * n;
214
+ if (raw) { for (let j = 0; j < n; j++) out[orow + j] = acc[j]; continue; }
215
+ const rscale = rs[bz * m + i];
216
+ for (let j = 0; j < n; j++) {
217
+ const v = epi(acc[j], rscale, cs[co + j]);
218
+ out[orow + j] = relu && v < 0 ? 0 : v;
219
+ }
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+
225
+ // Recompute a handful of RANDOM output cells of a live GEMM through the LUT
226
+ // mirror and compare against what the kernel produced. Sampling cells instead
227
+ // of whole matrices makes this cheap enough to run continuously, at the real
228
+ // shapes training uses — not once at boot on toy inputs.
229
+ // STRATIFIED sampling. Uniformly random cells are the wrong instrument for
230
+ // the bugs that actually occur here: a bounds-guard off-by-one or a pack-tail
231
+ // padding bug lives on the LAST row/column, and uniform sampling finds that
232
+ // with probability ~1/n per cell — at the 16512-wide logits GEMM, never. So
233
+ // the first cells are the structurally dangerous ones (corners, last row,
234
+ // last column, last batch) chosen deterministically, and the remainder are
235
+ // random interior cells that catch diffuse bugs. Same principle as poisoning
236
+ // the buffer pool: construct the dangerous case, don't wait to land on it.
237
+ function auditTile(Xq, Wq, rs, cs, d, got, L, nCells) {
238
+ const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
239
+ const N = nCells || 8;
240
+ const edges = [[0, m - 1, n - 1], [0, 0, n - 1], [0, m - 1, 0], [0, 0, 0],
241
+ [batch - 1, m - 1, n - 1], [batch - 1, 0, 0]];
242
+ for (let t = 0; t < N; t++) {
243
+ let bz, i, j;
244
+ if (t < edges.length) { bz = edges[t][0]; i = edges[t][1]; j = edges[t][2]; }
245
+ else { bz = (Math.random() * batch) | 0; i = (Math.random() * m) | 0; j = (Math.random() * n) | 0; }
246
+ let acc = 0;
247
+ const xrow = bz * m * k + i * k, wo = bz * k * n;
248
+ for (let p = 0; p < k; p++) acc += mul[(Xq[xrow + p] & 0xFF) * 256 + (Wq[wo + p * n + j] & 0xFF)];
249
+ let v = epi(acc, rs[bz * m + i], cs[bz * n + j]);
250
+ if (relu && v < 0) v = 0;
251
+ const idx = (bz * m + i) * n + j;
252
+ if (bitDiff(got[idx], v))
253
+ return `GEMM audit failed at [b${bz},${i},${j}] shape ${m}x${k}x${n}: kernel ${Object.is(got[idx], -0) ? "-0" : got[idx]} vs units ${Object.is(v, -0) ? "-0" : v}`;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ // ---- exact mirror of the split-K f32 GEMM ----------------------------------
259
+ // The f32 backward GEMM was the last kernel gated by a TOLERANCE (allclose at
260
+ // 1e-3) — and this project's own gate mutation test shows allclose waving
261
+ // through real bugs. The reason was real though: split-K accumulates in a
262
+ // different ORDER than a naive reference, so bit-equality against the naive
263
+ // one is impossible. The fix is the same as the epilogue mirror: reproduce
264
+ // the kernel's order exactly, then compare with `!==`.
265
+ // partials: for z in 0..S-1, sum p in [z*ks, min(k,(z+1)*ks)) in order
266
+ // reduce: sum the S partials in ascending z
267
+ // `fma` selects the rounding schedule for `s + a*b`: WGSL PERMITS a compiler
268
+ // to contract that into a fused multiply-add (one rounding) instead of two.
269
+ // Which one the device does is a fact about the device, so the gate tries
270
+ // both and reports which matches rather than assuming.
271
+ function fgemmMirror(A, Bm, d, fma) {
272
+ const { m, k, n } = d, transA = !!d.transA;
273
+ const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
274
+ const ks = Math.ceil(k / S);
275
+ const out = new Float32Array(m * n);
276
+ for (let row = 0; row < m; row++)
277
+ for (let col = 0; col < n; col++) {
278
+ let acc = 0; // reduce pass, ascending z
279
+ for (let z = 0; z < S; z++) {
280
+ const p0 = z * ks, p1 = Math.min(k, p0 + ks);
281
+ let s = 0; // one partial, in order
282
+ for (let p = p0; p < p1; p++) {
283
+ const a = transA ? A[p * m + row] : A[row * k + p];
284
+ s = fma ? f32(s + a * Bm[p * n + col]) // single rounding
285
+ : f32(s + f32(a * Bm[p * n + col]));
286
+ }
287
+ acc = f32(acc + s);
288
+ }
289
+ out[row * n + col] = acc;
290
+ }
291
+ return out;
292
+ }
293
+
294
+ // ---- live audits for the fused attention kernels ---------------------------
295
+ // The attention kernels had exact INIT gates but nothing at live shapes —
296
+ // the exact gap the GEMM audit exists to close, left open on the kernels with
297
+ // the trickiest indexing (head-strided gather, scatter write-back). These
298
+ // recompute individual output cells from the units, stratified like
299
+ // auditTile: last/first token pair, last head, last channel first, then
300
+ // random. Cost is hd (or T) multiply-adds per cell.
301
+ function auditAttScores(qq, kq, qs, ks, d, got, L, nCells) {
302
+ const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
303
+ const N = nCells || 8;
304
+ const edges = [[B - 1, heads - 1, T - 1, T - 1], [0, 0, 0, 0],
305
+ [0, heads - 1, T - 1, 0], [B - 1, 0, 0, T - 1]];
306
+ for (let t = 0; t < N; t++) {
307
+ let bi, h, ti, tj;
308
+ if (t < edges.length) { bi = edges[t][0]; h = edges[t][1]; ti = edges[t][2]; tj = edges[t][3]; }
309
+ else { bi = (Math.random() * B) | 0; h = (Math.random() * heads) | 0;
310
+ ti = (Math.random() * T) | 0; tj = (Math.random() * T) | 0; }
311
+ const bz = bi * heads + h;
312
+ const qo = (bi * T + ti) * C + h * hd, ko = (bi * T + tj) * C + h * hd;
313
+ let acc = 0;
314
+ for (let p = 0; p < hd; p++) acc += mul[(qq[qo + p] & 0xFF) * 256 + (kq[ko + p] & 0xFF)];
315
+ const v = raw ? acc : epi(acc, qs[(bi * T + ti) * heads + h], ks[(bi * T + tj) * heads + h]);
316
+ const idx = (bz * T + ti) * T + tj;
317
+ if (raw ? got[idx] !== v : bitDiff(got[idx], v))
318
+ return `att.scores audit failed at [b${bi},h${h},${ti},${tj}] B${B}T${T}H${heads}d${hd}: kernel ${got[idx]} vs units ${v}`;
319
+ }
320
+ return null;
321
+ }
322
+ function auditAttCtx(aq, vq, as, vs, d, got, L, nCells) {
323
+ const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
324
+ const N = nCells || 8;
325
+ const edges = [[B - 1, heads - 1, T - 1, hd - 1], [0, 0, 0, 0],
326
+ [0, heads - 1, T - 1, 0], [B - 1, 0, 0, hd - 1]];
327
+ for (let t = 0; t < N; t++) {
328
+ let bi, h, ti, j;
329
+ if (t < edges.length) { bi = edges[t][0]; h = edges[t][1]; ti = edges[t][2]; j = edges[t][3]; }
330
+ else { bi = (Math.random() * B) | 0; h = (Math.random() * heads) | 0;
331
+ ti = (Math.random() * T) | 0; j = (Math.random() * hd) | 0; }
332
+ const bz = bi * heads + h, ao = (bz * T + ti) * T;
333
+ let acc = 0;
334
+ for (let tj = 0; tj < T; tj++)
335
+ acc += mul[(aq[ao + tj] & 0xFF) * 256 + (vq[(bi * T + tj) * C + h * hd + j] & 0xFF)];
336
+ const v = raw ? acc : epi(acc, as[bz * T + ti], vs[(bi * heads + h) * hd + j]);
337
+ const idx = (bi * T + ti) * C + h * hd + j;
338
+ if (raw ? got[idx] !== v : bitDiff(got[idx], v))
339
+ return `att.ctx audit failed at [b${bi},h${h},${ti},${j}] B${B}T${T}H${heads}d${hd}: kernel ${got[idx]} vs units ${v}`;
340
+ }
341
+ return null;
342
+ }
343
+
344
+ // block-scaled verified GEMM, float in → float out.
345
+ // d = { m, k, n, batch=1, relu=false }; X is (batch·m)×k, W is batch×(k×n)
346
+ // gpuBgemm (from webgpu.js) runs the batched kernel with the fused epilogue;
347
+ // without it the CPU LUT mirror runs. Every product goes through the units.
348
+ async function vgemmBlock(Xf, Wf, d, L, gpuBgemm, audit) {
349
+ const { m, k, n } = d, batch = d.batch || 1;
350
+ const x = quantizeRows(Xf, batch * m, k);
351
+ let wq, ws;
352
+ if (batch === 1) {
353
+ const w = quantizeCols(Wf, k, n); wq = w.q; ws = w.s;
354
+ } else {
355
+ wq = new Int8Array(batch * k * n); ws = new Float32Array(batch * n);
356
+ for (let bz = 0; bz < batch; bz++) {
357
+ const w = quantizeCols(Wf.subarray(bz * k * n, (bz + 1) * k * n), k, n);
358
+ wq.set(w.q, bz * k * n); ws.set(w.s, bz * n);
359
+ }
360
+ }
361
+ if (gpuBgemm) {
362
+ const out = await gpuBgemm(x.q, wq, x.s, ws, d);
363
+ // continuous re-verification at LIVE shapes: the boot gate only ever saw
364
+ // toy inputs, so sample a few real cells against the units as we go
365
+ if (audit && audit.due()) {
366
+ const bad = auditTile(x.q, wq, x.s, ws, d, out, L, audit.cells);
367
+ if (bad) audit.fail(bad);
368
+ }
369
+ return out;
370
+ }
371
+ return bgemmJS(x.q, wq, x.s, ws, d, L);
372
+ }
373
+
374
+ // ---- gather-fused attention through the units (CUTLASS ex. 36/52) ----------
375
+ // The kernels read q/k/v/ctx directly in their natural BT×C layout with
376
+ // head-strided indexing — no JS gather copies, no kᵀ transpose, and the
377
+ // context write scatters straight back into BT×C. Quantization stays
378
+ // block-scaled: q/k/a per (token,head) row, v per (head,channel) column.
379
+ // The (BT·heads)×hd row view of q/k IS the contiguous buffer, so
380
+ // quantizeRows(q, BT·heads, hd) gives per-(token,head) scales for free.
381
+ function quantizeHeadCols(v, B, T, heads, hd) { // per (batch,head,channel) column
382
+ const C = heads * hd;
383
+ const q = new Int8Array(B * T * C), s = new Float32Array(B * heads * hd);
384
+ for (let bi = 0; bi < B; bi++)
385
+ for (let h = 0; h < heads; h++)
386
+ for (let j = 0; j < hd; j++) {
387
+ let mx = 0;
388
+ for (let ti = 0; ti < T; ti++) {
389
+ const a = Math.abs(v[(bi * T + ti) * C + h * hd + j]);
390
+ if (a > mx) mx = a;
391
+ }
392
+ const sc = Math.max(mx / 127, 1e-8);
393
+ s[(bi * heads + h) * hd + j] = sc;
394
+ for (let ti = 0; ti < T; ti++) {
395
+ const idx = (bi * T + ti) * C + h * hd + j;
396
+ const w = Math.round(v[idx] / sc);
397
+ q[idx] = w < -128 ? -128 : w > 127 ? 127 : w;
398
+ }
399
+ }
400
+ return { q, s };
401
+ }
402
+ // scores S[bz,ti,tj] = q_row(bi,ti,h) · k_row(bi,tj,h), every product via the LUT
403
+ // d.acc=true returns the raw int32 accumulator (exact oracle for the kernel gate)
404
+ function attScoresJS(qq, kq, qs, ks, d, L) {
405
+ const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
406
+ const out = raw ? new Int32Array(B * heads * T * T) : new Float32Array(B * heads * T * T);
407
+ for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
408
+ const bz = bi * heads + h;
409
+ for (let ti = 0; ti < T; ti++) {
410
+ const qo = (bi * T + ti) * C + h * hd, rscale = qs[(bi * T + ti) * heads + h];
411
+ for (let tj = 0; tj < T; tj++) {
412
+ const ko = (bi * T + tj) * C + h * hd;
413
+ let acc = 0;
414
+ for (let p = 0; p < hd; p++) acc += mul[(qq[qo + p] & 0xFF) * 256 + (kq[ko + p] & 0xFF)];
415
+ out[(bz * T + ti) * T + tj] = raw ? acc : epi(acc, rscale, ks[(bi * T + tj) * heads + h]);
416
+ }
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+ // ctx[(bi,ti),(h,j)] = Σ_tj a[bz,ti,tj]·v[(bi,tj),(h,j)] — scatter fused into BT×C
422
+ function attCtxJS(aq, vq, as, vs, d, L) {
423
+ const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
424
+ const out = raw ? new Int32Array(B * T * C) : new Float32Array(B * T * C);
425
+ for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
426
+ const bz = bi * heads + h;
427
+ for (let ti = 0; ti < T; ti++) {
428
+ const ao = (bz * T + ti) * T, rscale = as[bz * T + ti];
429
+ for (let j = 0; j < hd; j++) {
430
+ let acc = 0;
431
+ for (let tj = 0; tj < T; tj++)
432
+ acc += mul[(aq[ao + tj] & 0xFF) * 256 + (vq[(bi * T + tj) * C + h * hd + j] & 0xFF)];
433
+ out[(bi * T + ti) * C + h * hd + j] = raw ? acc : epi(acc, rscale, vs[(bi * heads + h) * hd + j]);
434
+ }
435
+ }
436
+ }
437
+ return out;
438
+ }
439
+
440
+ // one verified layer forward; returns float out (+ cache for STE backward).
441
+ // Every product goes through the verified INT8 multiply (mul8 LUT) with exact
442
+ // int32 accumulation — i.e. an emulated INT8 tensor-core GEMM — then dequant.
443
+ async function linearFwd(X, W, m, k, n, L, useRelu, matmulInt8) {
444
+ const xq = quantize(X), wq = quantize(W);
445
+ const acc = await (matmulInt8 || lutMatmulJS)(xq.q, wq.q, m, k, n, L); // verified multiply
446
+ const dq = xq.scale * wq.scale;
447
+ const out = new Float32Array(m * n);
448
+ const mask = useRelu ? new Uint8Array(m * n) : null;
449
+ for (let i = 0; i < m * n; i++) {
450
+ let v = acc[i] * dq;
451
+ if (useRelu) { if (v > 0) mask[i] = 1; else v = 0; }
452
+ out[i] = v;
453
+ }
454
+ return { out, mask };
455
+ }
456
+
457
+ // 2-layer MLP: X→H (relu) →dout. Forward through verified units, MSE loss.
458
+ async function forward(X, y, W1, W2, D, L, matmulInt8) {
459
+ const { n, din, h, dout } = D;
460
+ const l1 = await linearFwd(X, W1, n, din, h, L, true, matmulInt8);
461
+ const l2 = await linearFwd(l1.out, W2, n, h, dout, L, false, matmulInt8);
462
+ const resid = new Float32Array(n * dout); let loss = 0;
463
+ for (let i = 0; i < resid.length; i++) { const r = l2.out[i] - y[i]; resid[i] = r; loss += r * r; }
464
+ loss /= resid.length;
465
+ return { loss, resid, z1: l1.out, mask1: l1.mask };
466
+ }
467
+
468
+ // STE backward (verified matmul treated as float X@W). Returns flat [gW1, gW2].
469
+ function backward(X, W1, W2, fwd, D) {
470
+ const { n, din, h, dout } = D;
471
+ const { resid, z1, mask1 } = fwd;
472
+ const s = 2 / n;
473
+ const dout_ = new Float32Array(resid.length);
474
+ for (let i = 0; i < resid.length; i++) dout_[i] = resid[i] * s;
475
+ const mm = TC.matmul, tr = TC.transpose;
476
+ const gW2 = mm(tr(z1, n, h), dout_, h, n, dout); // z1ᵀ @ dout
477
+ const dz1 = mm(dout_, tr(W2, h, dout), n, dout, h); // dout @ W2ᵀ
478
+ for (let i = 0; i < dz1.length; i++) if (!mask1[i]) dz1[i] = 0; // relu grad
479
+ const gW1 = mm(tr(X, n, din), dz1, din, n, h); // Xᵀ @ dz1
480
+ const g = new Float32Array(gW1.length + gW2.length);
481
+ g.set(gW1, 0); g.set(gW2, gW1.length);
482
+ return g;
483
+ }
484
+
485
+ function splitApply(W1, W2, gAvg, lr) {
486
+ for (let i = 0; i < W1.length; i++) W1[i] -= lr * gAvg[i];
487
+ for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
488
+ }
489
+
490
+ const api = { quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
491
+ bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
492
+ rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
493
+ auditAttScores, auditAttCtx, fgemmMirror };
494
+ if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
495
+ else { TC = root.TrainCore; root.Verified = api; }
496
+ })(typeof self !== "undefined" ? self : this);
public/webgpu.js ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // WebGPU INT8 matmul via the verified multiply LUT — the emulated GPU logic
2
+ // running on the browser's GPU. Automatic CPU fallback (same LUT) for machines
3
+ // without WebGPU (e.g. old PCs via Supermium). initCompute() returns
4
+ // { backend, label, matmulInt8(Xq, Wq, m, k, n, L) -> Int32Array }
5
+ // matching Verified.lutMatmulJS, so the trainer is device-blind.
6
+ //
7
+ // High-throughput path: when the browser exposes WGSL's
8
+ // packed_4x8_integer_dot_product feature, we use dot4I8Packed — it compiles to
9
+ // the GPU's DP4A/INT8 dot-product hardware (the same units tensor-core INT8
10
+ // paths are built on): 4 exact int8 MACs per instruction, int32 accumulation,
11
+ // and 4× less memory traffic from packing. Because int8×int8→int32 is exact,
12
+ // it is bit-identical to the verified mul8 LUT — and we PROVE that at init by
13
+ // cross-checking random matmuls against the LUT before trusting it. If the
14
+ // hardware ever disagrees with the units, we fall back to the LUT shader.
15
+ (function (root) {
16
+ "use strict";
17
+
18
+ const WGSL_LUT = `
19
+ @group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
20
+ @group(0) @binding(1) var<storage, read> Wq : array<i32>;
21
+ @group(0) @binding(2) var<storage, read> lut : array<i32>; // 65536 signed products
22
+ @group(0) @binding(3) var<storage, read_write> C : array<i32>;
23
+ @group(0) @binding(4) var<uniform> dims : vec3<u32>; // m, k, n
24
+ @compute @workgroup_size(8, 8)
25
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
26
+ let m = dims.x; let k = dims.y; let n = dims.z;
27
+ let row = gid.x; let col = gid.y;
28
+ if (row >= m || col >= n) { return; }
29
+ var s : i32 = 0;
30
+ for (var p = 0u; p < k; p = p + 1u) {
31
+ let au = u32(Xq[row * k + p] & 255);
32
+ let bu = u32(Wq[p * n + col] & 255);
33
+ s = s + lut[au * 256u + bu];
34
+ }
35
+ C[row * n + col] = s;
36
+ }`;
37
+
38
+ // ---- B2B MLP chain kernels (CUTLASS ex. 13 + 23) ---------------------------
39
+ // ROWMAX: per-row |max| of a GEMM's f32 output, fused into the same command
40
+ // encoder (ex. 23 epilogue reduction). Non-negative f32 bit patterns order
41
+ // like u32, so atomicMax on bitcast(abs(v)) computes an EXACT max, in any
42
+ // execution order, on any hardware — nothing here can round.
43
+ const WGSL_ROWMAX = `
44
+ @group(0) @binding(0) var<storage, read> O : array<f32>;
45
+ @group(0) @binding(1) var<storage, read_write> MX : array<atomic<u32>>;
46
+ @group(0) @binding(2) var<uniform> dims : vec4<u32>; // m, n, _, _
47
+ @compute @workgroup_size(8, 8, 1)
48
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
49
+ let m = dims.x; let n = dims.y;
50
+ let row = gid.x; let col = gid.y;
51
+ if (row >= m || col >= n) { return; }
52
+ atomicMax(&MX[row], bitcast<u32>(abs(O[row * n + col])));
53
+ }`;
54
+ // QUANT: h1 (f32, still on the GPU) -> int8 by MULTIPLY with a JS-computed
55
+ // inverse scale. floor(f32(x*inv)+0.5) uses only ops WGSL guarantees exact
56
+ // or correctly rounded (mul, add, floor, clamp) — division is 2.5 ULP and
57
+ // never runs on the GPU. Bit-identical to Verified.quantizeRowsInv, and
58
+ // exact-gated against it at init. pack=true emits 4 bytes per u32 for the
59
+ // DP4A kernel; pack=false emits one i32 per element for the LUT kernel.
60
+ const WGSL_QUANT = (pack) => `
61
+ @group(0) @binding(0) var<storage, read> H : array<f32>;
62
+ @group(0) @binding(1) var<storage, read> inv : array<f32>; // per row
63
+ @group(0) @binding(2) var<storage, read_write> Q : array<${pack ? "u32" : "i32"}>;
64
+ @group(0) @binding(3) var<uniform> dims : vec4<u32>; // m, k, kw, _
65
+ @compute @workgroup_size(64)
66
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
67
+ let m = dims.x; let k = dims.y; let kw = dims.z;
68
+ let idx = gid.x;
69
+ ${pack ? `
70
+ if (idx >= m * kw) { return; }
71
+ let row = idx / kw;
72
+ var acc : u32 = 0u;
73
+ for (var b = 0u; b < 4u; b = b + 1u) {
74
+ let c = (idx % kw) * 4u + b;
75
+ var q : i32 = 0;
76
+ if (c < k) {
77
+ let v = clamp(floor(H[row * k + c] * inv[row] + 0.5), -128.0, 127.0);
78
+ q = i32(v);
79
+ }
80
+ acc = acc | ((u32(q) & 255u) << (8u * b));
81
+ }
82
+ Q[idx] = acc;` : `
83
+ if (idx >= m * k) { return; }
84
+ let row = idx / k;
85
+ let v = clamp(floor(H[idx] * inv[row] + 0.5), -128.0, 127.0);
86
+ Q[idx] = i32(v);`}
87
+ }`;
88
+
89
+ // NOTE: the un-batched DP4A matmul that used to live here was removed. It was
90
+ // the only kernel with an exact gate, but the transformer stopped calling it
91
+ // when the block-scaled path landed — so it sat here passing its own gate
92
+ // while verifying nothing that ran. A gate on a kernel nobody calls is worse
93
+ // than no gate: it reads like coverage. The batched kernels below are the
94
+ // ones training uses, and they now carry that exact gate instead.
95
+
96
+ // Batched block-scaled GEMM with a FUSED EPILOGUE (CUTLASS ex. 05/24 + 12):
97
+ // grid z = batch index, so ALL attention heads run in ONE dispatch, and the
98
+ // epilogue (block dequant rs·cs + optional ReLU) happens before the data
99
+ // leaves the GPU — f32 out, no int32 readback, no second pass in JS.
100
+ // Each kernel is emitted in two variants from ONE source string: the live one
101
+ // (fused epilogue, f32 out) and a `verify` one that writes the raw int32
102
+ // accumulator instead. The indexing — the part that actually goes wrong — is
103
+ // textually identical, so gating the verify variant genuinely gates the live
104
+ // kernel, and the comparison is EXACT (int8xint8->int32 has no rounding to
105
+ // hide in) instead of an allclose that whole bug classes walk through.
106
+ const OUT_DECL = (v, b) => `@group(0) @binding(${b}) var<storage, read_write> O : array<${v ? "i32" : "f32"}>;`;
107
+
108
+ const WGSL_BG_LUT = (verify) => `
109
+ @group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
110
+ @group(0) @binding(1) var<storage, read> Wq : array<i32>;
111
+ @group(0) @binding(2) var<storage, read> lut : array<i32>;
112
+ @group(0) @binding(3) var<storage, read> rs : array<f32>; // per (batch,row)
113
+ @group(0) @binding(4) var<storage, read> cs : array<f32>; // per (batch,col)
114
+ ${OUT_DECL(verify, 5)}
115
+ @group(0) @binding(6) var<uniform> dims : vec4<u32>; // m, k, n, flags(1=relu)
116
+ @compute @workgroup_size(8, 8, 1)
117
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
118
+ let m = dims.x; let k = dims.y; let n = dims.z;
119
+ let row = gid.x; let col = gid.y; let bz = gid.z;
120
+ if (row >= m || col >= n) { return; }
121
+ var s : i32 = 0;
122
+ let xo = (bz * m + row) * k;
123
+ let wo = bz * k * n + col;
124
+ for (var p = 0u; p < k; p = p + 1u) {
125
+ let au = u32(Xq[xo + p] & 255);
126
+ let bu = u32(Wq[wo + p * n] & 255);
127
+ s = s + lut[au * 256u + bu];
128
+ }
129
+ ${verify ? `O[(bz * m + row) * n + col] = s;` : `
130
+ var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
131
+ if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
132
+ O[(bz * m + row) * n + col] = v;`}
133
+ }`;
134
+
135
+ // same fused/batched kernel on the DP4A hardware path (Wᵀ packed per batch)
136
+ const WGSL_BG_DP4 = (verify) => `
137
+ @group(0) @binding(0) var<storage, read> Xp : array<u32>;
138
+ @group(0) @binding(1) var<storage, read> Wp : array<u32>; // per-batch Wᵀ, packed
139
+ @group(0) @binding(2) var<storage, read> rs : array<f32>;
140
+ @group(0) @binding(3) var<storage, read> cs : array<f32>;
141
+ ${OUT_DECL(verify, 4)}
142
+ @group(0) @binding(5) var<uniform> dims : vec4<u32>; // m, kw, n, flags
143
+ @compute @workgroup_size(8, 8, 1)
144
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
145
+ let m = dims.x; let kw = dims.y; let n = dims.z;
146
+ let row = gid.x; let col = gid.y; let bz = gid.z;
147
+ if (row >= m || col >= n) { return; }
148
+ var s : i32 = 0;
149
+ let xo = (bz * m + row) * kw;
150
+ let wo = (bz * n + col) * kw;
151
+ for (var p = 0u; p < kw; p = p + 1u) {
152
+ s = s + dot4I8Packed(Xp[xo + p], Wp[wo + p]);
153
+ }
154
+ ${verify ? `O[(bz * m + row) * n + col] = s;` : `
155
+ var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
156
+ if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
157
+ O[(bz * m + row) * n + col] = v;`}
158
+ }`;
159
+
160
+ // Gather-fused attention (CUTLASS ex. 36/52): kernels index q/k/v directly in
161
+ // their BT×C layout (head-strided) — no gather copies, no kᵀ transpose — and
162
+ // the ctx kernel scatters straight back into BT×C. int8×int8→i32 is exact, so
163
+ // these are bit-identical to the LUT mirrors (proved at init before use).
164
+ const WGSL_ATT_SCORES = (verify) => `
165
+ @group(0) @binding(0) var<storage, read> Q : array<i32>; // int8 per elem, BT×C
166
+ @group(0) @binding(1) var<storage, read> K : array<i32>;
167
+ @group(0) @binding(2) var<storage, read> qs : array<f32>; // per (token,head)
168
+ @group(0) @binding(3) var<storage, read> ks : array<f32>;
169
+ ${OUT_DECL(verify, 4)}
170
+ @group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
171
+ @compute @workgroup_size(8, 8, 1)
172
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
173
+ let T = dims.x; let heads = dims.y; let hd = dims.z;
174
+ let ti = gid.x; let tj = gid.y; let bz = gid.z;
175
+ if (ti >= T || tj >= T) { return; }
176
+ let bi = bz / heads; let h = bz % heads;
177
+ let C = heads * hd;
178
+ let qo = (bi * T + ti) * C + h * hd;
179
+ let ko = (bi * T + tj) * C + h * hd;
180
+ var s : i32 = 0;
181
+ for (var p = 0u; p < hd; p = p + 1u) { s = s + Q[qo + p] * K[ko + p]; }
182
+ ${verify ? `O[(bz * T + ti) * T + tj] = s;`
183
+ : `O[(bz * T + ti) * T + tj] = f32(s) * qs[(bi * T + ti) * heads + h] * ks[(bi * T + tj) * heads + h];`}
184
+ }`;
185
+ const WGSL_ATT_CTX = (verify) => `
186
+ @group(0) @binding(0) var<storage, read> A : array<i32>; // int8, BH×T×T
187
+ @group(0) @binding(1) var<storage, read> V : array<i32>; // int8, BT×C
188
+ @group(0) @binding(2) var<storage, read> as_ : array<f32>; // per (bz,row)
189
+ @group(0) @binding(3) var<storage, read> vs : array<f32>; // per (batch,head,chan)
190
+ ${OUT_DECL(verify, 4)} // BT×C (scatter fused)
191
+ @group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
192
+ @compute @workgroup_size(8, 8, 1)
193
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
194
+ let T = dims.x; let heads = dims.y; let hd = dims.z;
195
+ let ti = gid.x; let j = gid.y; let bz = gid.z;
196
+ if (ti >= T || j >= hd) { return; }
197
+ let bi = bz / heads; let h = bz % heads;
198
+ let C = heads * hd;
199
+ let ao = (bz * T + ti) * T;
200
+ var s : i32 = 0;
201
+ for (var tj = 0u; tj < T; tj = tj + 1u) { s = s + A[ao + tj] * V[(bi * T + tj) * C + h * hd + j]; }
202
+ ${verify ? `O[(bi * T + ti) * C + h * hd + j] = s;`
203
+ : `O[(bi * T + ti) * C + h * hd + j] = f32(s) * as_[bz * T + ti] * vs[(bi * heads + h) * hd + j];`}
204
+ }`;
205
+
206
+ // Split-K f32 GEMM (CUTLASS ex. 06) for the STE BACKWARD only — the backward
207
+ // was always float (the integer path has no gradient); this just moves that
208
+ // exact float math off the JS thread. Split-K matters for dlnf: M=256, N=32,
209
+ // K=16512 — 8k outputs with a huge inner loop would idle the GPU, so slices
210
+ // of K run on separate workgroups and a second tiny pass reduces partials.
211
+ const WGSL_FGEMM = `
212
+ @group(0) @binding(0) var<storage, read> A : array<f32>;
213
+ @group(0) @binding(1) var<storage, read> Bm : array<f32>;
214
+ @group(0) @binding(2) var<storage, read_write> P : array<f32>; // S partials
215
+ @group(0) @binding(3) var<uniform> dims : vec4<u32>; // m, k, n, flags(bit0=transA, rest=S)
216
+ @compute @workgroup_size(8, 8, 1)
217
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
218
+ let m = dims.x; let k = dims.y; let n = dims.z;
219
+ let transA = (dims.w & 1u) == 1u;
220
+ let S = dims.w >> 1u;
221
+ let row = gid.x; let col = gid.y; let z = gid.z;
222
+ if (row >= m || col >= n) { return; }
223
+ let ks = (k + S - 1u) / S;
224
+ let p0 = z * ks;
225
+ let p1 = min(k, p0 + ks);
226
+ var s : f32 = 0.0;
227
+ for (var p = p0; p < p1; p = p + 1u) {
228
+ let a = select(A[row * k + p], A[p * m + row], transA);
229
+ s = s + a * Bm[p * n + col];
230
+ }
231
+ P[(z * m + row) * n + col] = s;
232
+ }`;
233
+ const WGSL_FREDUCE = `
234
+ @group(0) @binding(0) var<storage, read> P : array<f32>;
235
+ @group(0) @binding(1) var<storage, read_write> O : array<f32>;
236
+ @group(0) @binding(2) var<uniform> dims : vec4<u32>; // mn, S, _, _
237
+ @compute @workgroup_size(64)
238
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
239
+ let mn = dims.x; let S = dims.y;
240
+ let i = gid.x;
241
+ if (i >= mn) { return; }
242
+ var s : f32 = 0.0;
243
+ for (var z = 0u; z < S; z = z + 1u) { s = s + P[z * mn + i]; }
244
+ O[i] = s;
245
+ }`;
246
+
247
+ async function loadLUTs(base) {
248
+ base = base || "";
249
+ const [mulB, reqB, reluB, meta] = await Promise.all([
250
+ fetch(base + "mul_lut.bin").then(r => r.arrayBuffer()),
251
+ fetch(base + "requant_lut.bin").then(r => r.arrayBuffer()),
252
+ fetch(base + "relu_lut.bin").then(r => r.arrayBuffer()),
253
+ fetch(base + "luts_meta.json").then(r => r.json()),
254
+ ]);
255
+ return { mul: new Int16Array(mulB), requant: new Int8Array(reqB),
256
+ relu: new Int8Array(reluB), shift: meta.shift };
257
+ }
258
+
259
+ // pack a row-major int8 matrix (rows×cols) into u32 words of 4 bytes along
260
+ // cols, zero-padded to kw words per row (zeros contribute 0 to the dot)
261
+ function packRows(Q, rows, cols, kw) {
262
+ const out = new Uint32Array(rows * kw);
263
+ const bytes = new Uint8Array(out.buffer);
264
+ for (let r = 0; r < rows; r++)
265
+ for (let c = 0; c < cols; c++) bytes[(r * kw * 4) + c] = Q[r * cols + c] & 0xFF;
266
+ return out;
267
+ }
268
+ function transposeI8(Q, rows, cols) {
269
+ const out = new Int8Array(rows * cols);
270
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) out[c * rows + r] = Q[r * cols + c];
271
+ return out;
272
+ }
273
+
274
+ // f32 gate equality AT THE BIT LEVEL. JS `!==` says -0 === 0, but the fleet
275
+ // compares devices by hashing raw bit patterns (FNV over the byte buffer), so
276
+ // a kernel that flushes -0 to +0 — real hardware has instructions that do
277
+ // exactly this (RDNA2 output modifiers and DX9-legacy multiplies are
278
+ // documented as "not IEEE compatible: -0 is flushed to +0") — would pass a
279
+ // `!==` gate and still fork the weights at the sync guard. The gates must
280
+ // compare the same thing the hash sees: the bits.
281
+ const _fb = new Float32Array(1), _ub = new Uint32Array(_fb.buffer);
282
+ function bitDiff(a, b) { _fb[0] = a; const u = _ub[0]; _fb[0] = b; return u !== _ub[0]; }
283
+
284
+ async function initCompute(L) {
285
+ const cpu = { backend: "cpu", label: "CPU (JS)",
286
+ matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
287
+ if (!(root.navigator && navigator.gpu)) return cpu;
288
+ try {
289
+ const adapter = await navigator.gpu.requestAdapter();
290
+ if (!adapter) return cpu;
291
+ const device = await adapter.requestDevice();
292
+ const info = adapter.info || {};
293
+ const gpuName = info.description || info.vendor || "WebGPU";
294
+
295
+ // LUT pipeline (always built — the fallback and the verification oracle)
296
+ const lutModule = device.createShaderModule({ code: WGSL_LUT });
297
+ const lutPipe = device.createComputePipeline({ layout: "auto", compute: { module: lutModule, entryPoint: "main" } });
298
+ const lut32 = new Int32Array(L.mul); // widen int16 -> int32
299
+ const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
300
+ device.queue.writeBuffer(lutBuf, 0, lut32);
301
+ const mkPipe = (code) => device.createComputePipeline({ layout: "auto",
302
+ compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
303
+ // The verify variant doesn't reference the scale buffers, so `layout:auto`
304
+ // would strip those bindings and the bind group would silently mismatch.
305
+ // An EXPLICIT layout keeps both variants binding-compatible — which is the
306
+ // point: they must differ only in the final write, nothing else.
307
+ const mkLayout = (spec) => device.createBindGroupLayout({
308
+ entries: spec.map((t, i) => ({ binding: i, visibility: GPUShaderStage.COMPUTE,
309
+ buffer: { type: t === "u" ? "uniform" : t === "rw" ? "storage" : "read-only-storage" } })) });
310
+ const mkPipeL = (code, layout) => device.createComputePipeline({
311
+ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
312
+ compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
313
+ const bgLutLayout = mkLayout(["r", "r", "r", "r", "r", "rw", "u"]);
314
+ const bgDp4Layout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
315
+ const attLayout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
316
+ const rowmaxLayout = mkLayout(["r", "rw", "u"]);
317
+ const quantLayout = mkLayout(["r", "r", "rw", "u"]);
318
+ const rowmaxPipe = mkPipeL(WGSL_ROWMAX, rowmaxLayout);
319
+ const quantI32Pipe = mkPipeL(WGSL_QUANT(false), quantLayout);
320
+ const quantPackPipe = mkPipeL(WGSL_QUANT(true), quantLayout);
321
+ // live + verify variants, compiled from the same source (see WGSL_* above)
322
+ const bgLutPipe = mkPipeL(WGSL_BG_LUT(false), bgLutLayout), bgLutVPipe = mkPipeL(WGSL_BG_LUT(true), bgLutLayout);
323
+ const scoresPipe = mkPipeL(WGSL_ATT_SCORES(false), attLayout), scoresVPipe = mkPipeL(WGSL_ATT_SCORES(true), attLayout);
324
+ const ctxPipe = mkPipeL(WGSL_ATT_CTX(false), attLayout), ctxVPipe = mkPipeL(WGSL_ATT_CTX(true), attLayout);
325
+
326
+ // gather-fused attention kernels. The gate runs the VERIFY variant of the
327
+ // same source and compares the int32 accumulator with `!==` — exact, no
328
+ // tolerance — then checks the fused epilogue against a bit-exact JS mirror
329
+ // of the WGSL rounding. Swept over several shapes, incl. odd/ragged ones,
330
+ // because head-strided addressing is where these kernels can go wrong.
331
+ let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
332
+ ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
333
+ try {
334
+ for (const d0 of [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
335
+ { B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }]) {
336
+ const nQ = d0.B * d0.T * d0.heads * d0.hd;
337
+ const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
338
+ for (let i = 0; i < nQ; i++) { qq[i] = (Math.random() * 256 - 128) | 0; kq[i] = (Math.random() * 256 - 128) | 0; vq[i] = (Math.random() * 256 - 128) | 0; }
339
+ const qs = Float32Array.from({ length: d0.B * d0.T * d0.heads }, () => Math.random() + 0.5);
340
+ const ks = Float32Array.from({ length: d0.B * d0.T * d0.heads }, () => Math.random() + 0.5);
341
+ const aq = new Int8Array(d0.B * d0.heads * d0.T * d0.T);
342
+ for (let i = 0; i < aq.length; i++) aq[i] = (Math.random() * 127) | 0;
343
+ const as = Float32Array.from({ length: d0.B * d0.heads * d0.T }, () => Math.random() + 0.5);
344
+ const vs = Float32Array.from({ length: d0.B * d0.heads * d0.hd }, () => Math.random() + 0.5);
345
+ const dv = { ...d0, acc: true };
346
+ const [accS, accC, hwS, hwC] = await Promise.all([
347
+ att.scores(qq, kq, qs, ks, dv), att.ctx(aq, vq, as, vs, dv),
348
+ att.scores(qq, kq, qs, ks, d0), att.ctx(aq, vq, as, vs, d0)]);
349
+ const refAccS = root.Verified.attScoresJS(qq, kq, qs, ks, dv, L);
350
+ const refAccC = root.Verified.attCtxJS(aq, vq, as, vs, dv, L);
351
+ for (let i = 0; i < refAccS.length; i++) if (accS[i] !== refAccS[i]) throw new Error(`scores accumulator mismatch @${i} shape ${JSON.stringify(d0)}`);
352
+ for (let i = 0; i < refAccC.length; i++) if (accC[i] !== refAccC[i]) throw new Error(`ctx accumulator mismatch @${i} shape ${JSON.stringify(d0)}`);
353
+ const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
354
+ const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
355
+ for (let i = 0; i < refS.length; i++) if (bitDiff(hwS[i], refS[i])) throw new Error(`scores epilogue mismatch @${i}`);
356
+ for (let i = 0; i < refC.length; i++) if (bitDiff(hwC[i], refC[i])) throw new Error(`ctx epilogue mismatch @${i}`);
357
+ }
358
+ } catch (e) {
359
+ console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
360
+ att = null;
361
+ }
362
+
363
+ // split-K f32 GEMM for the STE backward (self-tested vs JS float matmul)
364
+ const fPipes = { gemm: mkPipe(WGSL_FGEMM), reduce: mkPipe(WGSL_FREDUCE) };
365
+ let fgemm = (A, Bm, d) => gpuFgemm(device, fPipes, A, Bm, d);
366
+ let fgemm2 = (A, B1, d1, B2, d2) => gpuFgemm2(device, fPipes, A, B1, d1, B2, d2);
367
+ let fgemmFma = false; // set by the gate below
368
+ try {
369
+ const m0 = 7, k0 = 4500, n0 = 5; // k big enough to exercise split-K
370
+ const A = Float32Array.from({ length: m0 * k0 }, () => Math.random() - 0.5);
371
+ const Bm = Float32Array.from({ length: k0 * n0 }, () => Math.random() - 0.5);
372
+ // EXACT gate, replacing an allclose. The mirror reproduces split-K's
373
+ // partition and accumulation order, so bit-equality is achievable and
374
+ // is the honest bar. WGSL may contract `s + a*b` into an FMA, so try
375
+ // both rounding schedules and record which the device implements —
376
+ // if NEITHER matches, the f32 backward is not bit-reproducible here
377
+ // and must not run, because its gradients set the weights the whole
378
+ // fleet is hashed against.
379
+ const dG = { m: m0, k: k0, n: n0 };
380
+ const hw = await fgemm(A, Bm, dG);
381
+ const mStep = root.Verified.fgemmMirror(A, Bm, dG, false);
382
+ const mFma = root.Verified.fgemmMirror(A, Bm, dG, true);
383
+ let okStep = true, okFma = true;
384
+ for (let i = 0; i < hw.length; i++) {
385
+ if (bitDiff(hw[i], mStep[i])) okStep = false;
386
+ if (bitDiff(hw[i], mFma[i])) okFma = false;
387
+ if (!okStep && !okFma) break;
388
+ }
389
+ if (!okStep && !okFma) throw new Error("fgemm matches neither rounding schedule — not bit-reproducible");
390
+ fgemmFma = !okStep; // remember what this device does
391
+ console.info(`split-K f32 GEMM is bit-exact (${okStep ? "two-rounding" : "fused"} multiply-add schedule)`);
392
+ // The shared-operand pair must equal the two calls it replaces, EXACTLY:
393
+ // same buffer, same shader, same arithmetic, so bit-level equality is
394
+ // the honest bar (not a tolerance). Both index patterns of A are
395
+ // exercised — transA on one leg, plain on the other, the live shapes.
396
+ const kT = 300, mT = 9, nT = 6; // A is mT x kT, used both ways
397
+ const A2 = Float32Array.from({ length: mT * kT }, () => Math.random() - 0.5);
398
+ const Bt = Float32Array.from({ length: mT * nT }, () => Math.random() - 0.5);
399
+ const Bn = Float32Array.from({ length: kT * nT }, () => Math.random() - 0.5);
400
+ const dT = { m: kT, k: mT, n: nT, transA: true }, dN = { m: mT, k: kT, n: nT };
401
+ const [f1, f2] = await fgemm2(A2, Bt, dT, Bn, dN);
402
+ const [r1, r2] = [await fgemm(A2, Bt, dT), await fgemm(A2, Bn, dN)];
403
+ for (let i = 0; i < r1.length; i++) if (bitDiff(f1[i], r1[i])) throw new Error("fgemm2 transA leg differs");
404
+ for (let i = 0; i < r2.length; i++) if (bitDiff(f2[i], r2[i])) throw new Error("fgemm2 plain leg differs");
405
+ } catch (e) {
406
+ console.warn("split-K f32 GEMM failed verification — backward stays in JS:", e.message);
407
+ fgemm = null; fgemm2 = null;
408
+ }
409
+
410
+ // Shared exact gate for a bgemm implementation. Sweeps shapes (including
411
+ // ragged ones and a k long enough to matter), compares the int32
412
+ // accumulator from the verify variant with `!==`, then compares the fused
413
+ // f32 epilogue against the bit-exact JS mirror. Returns null if clean.
414
+ async function gateBgemm(bgFn) {
415
+ for (const d0 of [{ m: 5, k: 9, n: 6, batch: 3, relu: true },
416
+ { m: 32, k: 64, n: 32, batch: 1, relu: false },
417
+ { m: 7, k: 253, n: 5, batch: 2, relu: true },
418
+ { m: 1, k: 4, n: 1, batch: 1, relu: false },
419
+ { m: 17, k: 33, n: 9, batch: 1, relu: true }]) {
420
+ const Xq = new Int8Array(d0.batch * d0.m * d0.k), Wq = new Int8Array(d0.batch * d0.k * d0.n);
421
+ for (let i = 0; i < Xq.length; i++) Xq[i] = (Math.random() * 256 - 128) | 0;
422
+ for (let i = 0; i < Wq.length; i++) Wq[i] = (Math.random() * 256 - 128) | 0;
423
+ const rs = Float32Array.from({ length: d0.batch * d0.m }, () => Math.random() + 0.5);
424
+ const cs = Float32Array.from({ length: d0.batch * d0.n }, () => Math.random() + 0.5);
425
+ const shape = `${d0.m}x${d0.k}x${d0.n}b${d0.batch}`;
426
+ const accHw = await bgFn(Xq, Wq, rs, cs, { ...d0, acc: true });
427
+ const accRef = root.Verified.bgemmJS(Xq, Wq, rs, cs, { ...d0, acc: true }, L);
428
+ for (let i = 0; i < accRef.length; i++)
429
+ if (accHw[i] !== accRef[i]) return `accumulator mismatch @${i} (${shape}): ${accHw[i]} vs ${accRef[i]}`;
430
+ const hw = await bgFn(Xq, Wq, rs, cs, d0);
431
+ const ref = root.Verified.bgemmJS(Xq, Wq, rs, cs, d0, L);
432
+ for (let i = 0; i < ref.length; i++)
433
+ if (bitDiff(hw[i], ref[i])) return `epilogue mismatch @${i} (${shape}): ${hw[i]} vs ${ref[i]}`;
434
+ }
435
+ return null;
436
+ }
437
+
438
+ // Poison the pool with large-magnitude residue at the gate's own shapes,
439
+ // so a re-gate runs on RECYCLED (non-zero) buffers. See the dirty-buffer
440
+ // gate note below the MLP gate for why this matters.
441
+ async function poisonBgemm(bgFn) {
442
+ for (const d0 of [{ m: 5, k: 9, n: 6, batch: 3, relu: true },
443
+ { m: 32, k: 64, n: 32, batch: 1, relu: false },
444
+ { m: 7, k: 253, n: 5, batch: 2, relu: true },
445
+ { m: 1, k: 4, n: 1, batch: 1, relu: false },
446
+ { m: 17, k: 33, n: 9, batch: 1, relu: true }]) {
447
+ const Xq = new Int8Array(d0.batch * d0.m * d0.k).fill(127);
448
+ const Wq = new Int8Array(d0.batch * d0.k * d0.n).fill(127);
449
+ const rs = new Float32Array(d0.batch * d0.m).fill(1e4);
450
+ const cs = new Float32Array(d0.batch * d0.n).fill(1e4);
451
+ await bgFn(Xq, Wq, rs, cs, d0);
452
+ await bgFn(Xq, Wq, rs, cs, { ...d0, acc: true });
453
+ }
454
+ }
455
+ const gateBgemmDirty = async (bgFn) => { await poisonBgemm(bgFn); return gateBgemm(bgFn); };
456
+
457
+ const bgLut = (Xq, Wq, rs, cs, d) => gpuBgemmLUT(device, d.acc ? bgLutVPipe : bgLutPipe, lutBuf, Xq, Wq, rs, cs, d);
458
+ // the LUT bgemm is the fallback AND the oracle's shader twin — gate it too
459
+ const lutBad = (await gateBgemm(bgLut)) || (await gateBgemmDirty(bgLut));
460
+ if (lutBad) { console.warn("LUT bgemm shader failed verification — CPU mirrors only:", lutBad); return cpu; }
461
+
462
+ // B2B MLP chain gate (CUTLASS ex. 13+23): run the WHOLE chain — gemm1 +
463
+ // ReLU + on-GPU rowmax + on-GPU quantize + gemm2 — against the pure-JS
464
+ // mirror chain, exact `!==` on both h1 and the final output. Sweeps
465
+ // ragged shapes; h not a multiple of 4 exercises the pack-tail padding.
466
+ const MLP_SHAPES = [{ m: 6, k: 8, h: 12, n: 5 }, { m: 5, k: 16, h: 6, n: 3 },
467
+ { m: 17, k: 33, h: 10, n: 9 }, { m: 32, k: 64, h: 128, n: 32 }];
468
+ async function gateMlp(mlpFn, sweepOnly) {
469
+ for (const d0 of MLP_SHAPES) {
470
+ const rnd = (len) => Float32Array.from({ length: len }, () => Math.random() * 2 - 1);
471
+ const Xf = rnd(d0.m * d0.k), W1 = rnd(d0.k * d0.h), W2 = rnd(d0.h * d0.n);
472
+ const hw = await root.Verified.vmlpBlock(Xf, W1, W2, d0, L, mlpFn, null);
473
+ const ref = await root.Verified.vmlpBlock(Xf, W1, W2, d0, L, null, null);
474
+ const shape = `${d0.m}x${d0.k}x${d0.h}x${d0.n}`;
475
+ for (let i = 0; i < ref.h1.length; i++)
476
+ if (bitDiff(hw.h1[i], ref.h1[i])) return `h1 mismatch @${i} (${shape}): ${hw.h1[i]} vs ${ref.h1[i]}`;
477
+ for (let i = 0; i < ref.out.length; i++)
478
+ if (bitDiff(hw.out[i], ref.out[i])) return `out mismatch @${i} (${shape}): ${hw.out[i]} vs ${ref.out[i]}`;
479
+ }
480
+ if (sweepOnly) return null; // dirty re-gate: sweep is the point, skip the respec hunt
481
+ // DISCRIMINATING case: the sweep above passes vacuously if no value
482
+ // lands on a rounding boundary — the old round(x/scale) spec would
483
+ // pass it too. So hunt (in fast JS) for an input where the two specs
484
+ // actually disagree, then check the GPU sides with the RESPEC. This
485
+ // gates the gate: a pass must be something the old spec would fail.
486
+ const V = root.Verified;
487
+ const d1 = { m: 16, k: 32, h: 64, n: 16 };
488
+ const rnd1 = (len) => Float32Array.from({ length: len }, () => Math.random() * 4 - 2);
489
+ for (let t = 0; t < 800; t++) {
490
+ const Xf = rnd1(d1.m * d1.k), W1 = rnd1(d1.k * d1.h), W2 = rnd1(d1.h * d1.n);
491
+ const x = V.quantizeRows(Xf, d1.m, d1.k), w1 = V.quantizeCols(W1, d1.k, d1.h);
492
+ const h1 = V.bgemmJS(x.q, w1.q, x.s, w1.s, { m: d1.m, k: d1.k, n: d1.h, batch: 1, relu: true }, L);
493
+ const sc = V.scalesFromAbsMax(V.rowAbsMax(h1, d1.m, d1.h));
494
+ const qNew = V.quantizeRowsInv(h1, d1.m, d1.h, sc.inv);
495
+ const qOld = V.quantizeRows(h1, d1.m, d1.h).q;
496
+ let boundary = false;
497
+ for (let i = 0; i < qNew.length; i++) if (qNew[i] !== qOld[i]) { boundary = true; break; }
498
+ if (!boundary) continue;
499
+ const w2 = V.quantizeCols(W2, d1.h, d1.n);
500
+ const gpu = await mlpFn(x.q, w1.q, w2.q, x.s, w1.s, w2.s, d1);
501
+ const refNew = V.bgemmJS(qNew, w2.q, sc.scale, w2.s, { m: d1.m, k: d1.h, n: d1.n, batch: 1 }, L);
502
+ const refOld = V.bgemmJS(qOld, w2.q, sc.scale, w2.s, { m: d1.m, k: d1.h, n: d1.n, batch: 1 }, L);
503
+ let eqNew = true, eqOld = true;
504
+ for (let i = 0; i < refNew.length; i++) { if (bitDiff(gpu.out[i], refNew[i])) eqNew = false; if (bitDiff(gpu.out[i], refOld[i])) eqOld = false; }
505
+ if (!eqNew) return "discriminating boundary case: GPU chain does not match the respec mirror";
506
+ if (eqOld) return "discriminating boundary case: GPU chain matches the OLD quantize spec";
507
+ return null; // proven: respec, not merely gate-compatible
508
+ }
509
+ console.warn("B2B gate: no rounding-boundary input found in 800 trials — respec discrimination unproven this boot (sweep still exact)");
510
+ return null;
511
+ }
512
+ // FMA-contraction note for the quantize kernel: WGSL permits a compiler
513
+ // to contract `H*inv + 0.5` into a hardware FMA (e.g. RDNA2 V_FMA_F32 —
514
+ // ONE rounding instead of two). This CANNOT change the quantized int8:
515
+ // adding 0.5 is exact except at binade crossings, and there the
516
+ // double-rounding anomaly only moves the value within the same integer
517
+ // cell (the RNE tie parity resolves both schedules to the same side of
518
+ // the integer), so floor() sees no difference. Verified empirically in
519
+ // test_b2b.js: 48M draws targeted at binade edges, 1.9M last-ulp
520
+ // fused-vs-stepped differences, ZERO floor-visible. The `+0.5` respec is
521
+ // contraction-immune by construction — `round(x/scale)` was not.
522
+ // ---- DIRTY-BUFFER GATE ----------------------------------------------
523
+ // A pooled buffer is NOT zero-initialized, so any kernel that assumes
524
+ // zeros (the rowmax atomicMax accumulator does) is right on step one and
525
+ // wrong on step two. That is a STATE bug: no single call is wrong, the
526
+ // sequence is — the family an oracle cannot reach.
527
+ //
528
+ // MEASURED, and it corrected the assumption that motivated this code:
529
+ // the existing sweep ALREADY catches it. Deleting the clearBuffer and
530
+ // re-running showed the plain gate failing at the SECOND shape, because
531
+ // the sweep's own shapes recycle each other's buffers (same power-of-2
532
+ // bucket) and an uncleared max only grows. So the suite was never
533
+ // blind — it had incidental dirty coverage nobody designed.
534
+ //
535
+ // Incidental is the problem. It relies on the sweep having >= 2 shapes,
536
+ // on them colliding in one bucket, and on the residue exceeding the real
537
+ // value. Change the shape list and the coverage silently evaporates.
538
+ // The poison below makes it deliberate: run first at 1e4 magnitude so
539
+ // the residue dominates ANY value the gate can produce, release it, then
540
+ // sweep again. Detection stops depending on ordering luck and covers the
541
+ // first shape too. Cheap by design — the re-gate skips the 800-trial
542
+ // respec hunt, which has nothing to do with buffer state.
543
+ async function poisonPool(mlpFn) {
544
+ // same shapes as the gate => same pool buckets => the gate's next
545
+ // acquisition is exactly one of these poisoned buffers (free list is LIFO)
546
+ const big = (len) => Float32Array.from({ length: len }, () => (Math.random() * 2 - 1) * 1e4);
547
+ for (const d0 of MLP_SHAPES)
548
+ await root.Verified.vmlpBlock(big(d0.m * d0.k), big(d0.k * d0.h), big(d0.h * d0.n), d0, L, mlpFn, null);
549
+ }
550
+ async function gateMlpDirty(mlpFn) {
551
+ await poisonPool(mlpFn);
552
+ return gateMlp(mlpFn, true); // sweep only, on recycled non-zero buffers
553
+ }
554
+
555
+ const lutMlpEnv = { dp4: false, gemm: bgLutPipe, rowmax: rowmaxPipe, quant: quantI32Pipe, lutBuf };
556
+ let mlpLut = (xq, w1q, w2q, xs, w1s, w2s, d) => gpuMlpChain(device, lutMlpEnv, xq, w1q, w2q, xs, w1s, w2s, d);
557
+ const mlpLutBad = (await gateMlp(mlpLut)) || (await gateMlpDirty(mlpLut));
558
+ if (mlpLutBad) { console.warn("B2B MLP chain (LUT) failed verification — MLP stays on the CPU mirror chain:", mlpLutBad); mlpLut = null; }
559
+
560
+ const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
561
+ matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
562
+ bgemm: bgLut, att, fgemm, fgemm2, mlp: mlpLut };
563
+
564
+ // DP4A pipeline — only if the WGSL feature exists AND its batched kernel
565
+ // reproduces the verified units exactly across the shape sweep
566
+ if (!(navigator.gpu.wgslLanguageFeatures && navigator.gpu.wgslLanguageFeatures.has("packed_4x8_integer_dot_product")))
567
+ return viaLUT;
568
+ const bgDp4Pipe = mkPipeL(WGSL_BG_DP4(false), bgDp4Layout), bgDp4VPipe = mkPipeL(WGSL_BG_DP4(true), bgDp4Layout);
569
+ const bg = (Xq, Wq, rs, cs, d) => gpuBgemmDP4(device, d.acc ? bgDp4VPipe : bgDp4Pipe, Xq, Wq, rs, cs, d);
570
+ const dp4Bad = (await gateBgemm(bg)) || (await gateBgemmDirty(bg));
571
+ if (dp4Bad) {
572
+ console.warn("batched DP4A disagreed with the verified units — using LUT bgemm:", dp4Bad);
573
+ return viaLUT;
574
+ }
575
+ const dp4MlpEnv = { dp4: true, gemm: bgDp4Pipe, rowmax: rowmaxPipe, quant: quantPackPipe };
576
+ let mlpDp4 = (xq, w1q, w2q, xs, w1s, w2s, d) => gpuMlpChain(device, dp4MlpEnv, xq, w1q, w2q, xs, w1s, w2s, d);
577
+ const mlpDp4Bad = (await gateMlp(mlpDp4)) || (await gateMlpDirty(mlpDp4));
578
+ if (mlpDp4Bad) { console.warn("B2B MLP chain (DP4A) failed verification — using the LUT chain:", mlpDp4Bad); mlpDp4 = mlpLut; }
579
+
580
+ // Both backends are exact-gated bit-identical, so which one runs is a
581
+ // free choice — and an init race that timed them was tried and REMOVED.
582
+ // Measured on this NVIDIA part they tie on the shipped float path (DP4A's
583
+ // JS packing cancels its dot-throughput win at these sizes), so the race
584
+ // bought nothing, cost ~40 ms of init, and made the backend vary between
585
+ // page loads — which silently invalidated three separate A/B benchmarks
586
+ // before it was noticed. A knob that changes what you are measuring is
587
+ // worse than a fixed choice. DP4A ships: it wins the int8-backward mode
588
+ // by ~12% and ties elsewhere.
589
+ return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
590
+ bgemm: bg, att, fgemm, fgemm2, mlp: mlpDp4 };
591
+ } catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
592
+ }
593
+
594
+ // shared dispatch/readback plumbing
595
+ async function runPass(device, pipeline, entries, m, n) {
596
+ const bytesC = m * n * 4;
597
+ const bufC = mk(device, bytesC, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
598
+ const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0),
599
+ entries: entries(bufC) });
600
+ const enc = device.createCommandEncoder();
601
+ const pass = enc.beginComputePass();
602
+ pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
603
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8)); pass.end();
604
+ const read = mk(device, bytesC, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
605
+ enc.copyBufferToBuffer(bufC, 0, read, 0, bytesC);
606
+ device.queue.submit([enc.finish()]);
607
+ await read.mapAsync(GPUMapMode.READ);
608
+ const out = new Int32Array(read.getMappedRange(0, bytesC).slice(0)); // pooled: map only the logical range
609
+ read.unmap();
610
+ return { out, bufC, read };
611
+ }
612
+
613
+ async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
614
+ const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
615
+ const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
616
+ const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
617
+ const bufD = up(device, new Uint32Array([m, k, n, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
618
+ const r = await runPass(device, pipeline, (bufC) => [
619
+ { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
620
+ { binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufC } },
621
+ { binding: 4, resource: { buffer: bufD } } ], m, n);
622
+ release(device, [bufX, bufW, bufD, r.bufC, r.read]);
623
+ return r.out;
624
+ }
625
+
626
+ // attention kernels: int8 (widened i32) in, f32 out, strided head indexing
627
+ async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
628
+ const { B, T, heads } = d;
629
+ const bufQ = up(device, Int32Array.from(qq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
630
+ const bufK = up(device, Int32Array.from(kq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
631
+ const bufQs = up(device, qs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
632
+ const bufKs = up(device, ks, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
633
+ const bufD = up(device, new Uint32Array([d.T, d.heads, d.hd, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
634
+ const r = await runBgPass(device, pipeline, (bufO) => [
635
+ { binding: 0, resource: { buffer: bufQ } }, { binding: 1, resource: { buffer: bufK } },
636
+ { binding: 2, resource: { buffer: bufQs } }, { binding: 3, resource: { buffer: bufKs } },
637
+ { binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, T, B * heads, d.acc);
638
+ release(device, [bufQ, bufK, bufQs, bufKs, bufD, r.bufO, r.read]);
639
+ return r.out;
640
+ }
641
+ async function gpuAttCtx(device, pipeline, aq, vq, as, vs, d) {
642
+ const { B, T, heads, hd } = d;
643
+ const bufA = up(device, Int32Array.from(aq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
644
+ const bufV = up(device, Int32Array.from(vq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
645
+ const bufAs = up(device, as, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
646
+ const bufVs = up(device, vs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
647
+ const bufD = up(device, new Uint32Array([T, heads, hd, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
648
+ const r = await runBgPass(device, pipeline, (bufO) => [
649
+ { binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufV } },
650
+ { binding: 2, resource: { buffer: bufAs } }, { binding: 3, resource: { buffer: bufVs } },
651
+ { binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, hd, B * heads, d.acc);
652
+ release(device, [bufA, bufV, bufAs, bufVs, bufD, r.bufO, r.read]);
653
+ return r.out;
654
+ }
655
+
656
+ // split-K f32 GEMM (backward): partial pass + reduce pass
657
+ async function gpuFgemm(device, pipes, A, Bm, d) {
658
+ const { m, k, n } = d, transA = d.transA ? 1 : 0;
659
+ const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
660
+ const bufA = up(device, A, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
661
+ const bufB = up(device, Bm, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
662
+ const bufP = mk(device, S * m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
663
+ const bufD1 = up(device, new Uint32Array([m, k, n, transA | (S << 1)]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
664
+ const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
665
+ const bufD2 = up(device, new Uint32Array([m * n, S, 0, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
666
+ const enc = device.createCommandEncoder();
667
+ let pass = enc.beginComputePass();
668
+ pass.setPipeline(pipes.gemm);
669
+ pass.setBindGroup(0, device.createBindGroup({ layout: pipes.gemm.getBindGroupLayout(0), entries: [
670
+ { binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufB } },
671
+ { binding: 2, resource: { buffer: bufP } }, { binding: 3, resource: { buffer: bufD1 } } ] }));
672
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), S); pass.end();
673
+ pass = enc.beginComputePass();
674
+ pass.setPipeline(pipes.reduce);
675
+ pass.setBindGroup(0, device.createBindGroup({ layout: pipes.reduce.getBindGroupLayout(0), entries: [
676
+ { binding: 0, resource: { buffer: bufP } }, { binding: 1, resource: { buffer: bufO } },
677
+ { binding: 2, resource: { buffer: bufD2 } } ] }));
678
+ pass.dispatchWorkgroups(Math.ceil(m * n / 64)); pass.end();
679
+ const read = mk(device, m * n * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
680
+ enc.copyBufferToBuffer(bufO, 0, read, 0, m * n * 4);
681
+ device.queue.submit([enc.finish()]);
682
+ await read.mapAsync(GPUMapMode.READ);
683
+ const out = new Float32Array(read.getMappedRange(0, m * n * 4).slice(0)); // pooled: logical range only
684
+ read.unmap();
685
+ release(device, [bufA, bufB, bufP, bufD1, bufO, bufD2, read]);
686
+ return out;
687
+ }
688
+
689
+ // SHARED-OPERAND fused f32 GEMM pair. The two embedding-gradient GEMMs both
690
+ // consume dlogits (BT x vocab). With the 16512-token vocab that operand is
691
+ // ~17 MB, and running them as two independent calls uploaded it TWICE and
692
+ // paid two submits and two map round trips — profiling put the pair at 55%
693
+ // of the whole step. Here A goes up ONCE, both GEMM+reduce chains ride one
694
+ // command encoder, one submit covers both, and the two readbacks are mapped
695
+ // concurrently. The shader reads A as either A[row*k+p] or A[p*m+row]
696
+ // (transA), so ONE flat buffer serves both index patterns — nothing about
697
+ // the arithmetic changes, which is why the gradients stay bit-identical.
698
+ async function gpuFgemm2(device, pipes, A, B1, d1, B2, d2) {
699
+ const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
700
+ const UU = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST;
701
+ const bufA = up(device, A, SU); // the shared 17 MB operand: ONE upload
702
+ const enc = device.createCommandEncoder();
703
+ const legs = [];
704
+ for (const [Bm, d] of [[B1, d1], [B2, d2]]) {
705
+ const { m, k, n } = d, transA = d.transA ? 1 : 0;
706
+ const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
707
+ const bufB = up(device, Bm, SU);
708
+ const bufP = mk(device, S * m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
709
+ const bufD1 = up(device, new Uint32Array([m, k, n, transA | (S << 1)]), UU);
710
+ const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
711
+ const bufD2 = up(device, new Uint32Array([m * n, S, 0, 0]), UU);
712
+ let pass = enc.beginComputePass();
713
+ pass.setPipeline(pipes.gemm);
714
+ pass.setBindGroup(0, device.createBindGroup({ layout: pipes.gemm.getBindGroupLayout(0), entries: [
715
+ { binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufB } },
716
+ { binding: 2, resource: { buffer: bufP } }, { binding: 3, resource: { buffer: bufD1 } } ] }));
717
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), S); pass.end();
718
+ pass = enc.beginComputePass();
719
+ pass.setPipeline(pipes.reduce);
720
+ pass.setBindGroup(0, device.createBindGroup({ layout: pipes.reduce.getBindGroupLayout(0), entries: [
721
+ { binding: 0, resource: { buffer: bufP } }, { binding: 1, resource: { buffer: bufO } },
722
+ { binding: 2, resource: { buffer: bufD2 } } ] }));
723
+ pass.dispatchWorkgroups(Math.ceil(m * n / 64)); pass.end();
724
+ const bytes = m * n * 4;
725
+ const read = mk(device, bytes, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
726
+ enc.copyBufferToBuffer(bufO, 0, read, 0, bytes);
727
+ legs.push({ read, bytes, bufs: [bufB, bufP, bufD1, bufO, bufD2] });
728
+ }
729
+ device.queue.submit([enc.finish()]); // ONE submit for both GEMMs
730
+ await Promise.all(legs.map((l) => l.read.mapAsync(GPUMapMode.READ)));
731
+ const outs = legs.map((l) => {
732
+ const o = new Float32Array(l.read.getMappedRange(0, l.bytes).slice(0));
733
+ l.read.unmap();
734
+ return o;
735
+ });
736
+ release(device, [bufA, ...legs.flatMap((l) => [...l.bufs, l.read])]);
737
+ return outs;
738
+ }
739
+
740
+ // fused batched dispatch: f32 out, epilogue done on-device (raw=true reads the
741
+ // verify variant's int32 accumulator instead)
742
+ async function runBgPass(device, pipeline, entries, m, n, batch, raw) {
743
+ const bytesO = batch * m * n * 4;
744
+ const bufO = mk(device, bytesO, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
745
+ const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: entries(bufO) });
746
+ const enc = device.createCommandEncoder();
747
+ const pass = enc.beginComputePass();
748
+ pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
749
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), batch); pass.end();
750
+ const read = mk(device, bytesO, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
751
+ enc.copyBufferToBuffer(bufO, 0, read, 0, bytesO);
752
+ device.queue.submit([enc.finish()]);
753
+ await read.mapAsync(GPUMapMode.READ);
754
+ const buf = read.getMappedRange(0, bytesO).slice(0); // pooled: logical range only
755
+ const out = raw ? new Int32Array(buf) : new Float32Array(buf);
756
+ read.unmap();
757
+ return { out, bufO, read };
758
+ }
759
+
760
+ async function gpuBgemmLUT(device, pipeline, lutBuf, Xq, Wq, rs, cs, d) {
761
+ const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
762
+ const bufX = up(device, Int32Array.from(Xq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
763
+ const bufW = up(device, Int32Array.from(Wq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
764
+ const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
765
+ const bufS = up(device, cs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
766
+ const bufD = up(device, new Uint32Array([m, k, n, flags]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
767
+ const r = await runBgPass(device, pipeline, (bufO) => [
768
+ { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
769
+ { binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufR } },
770
+ { binding: 4, resource: { buffer: bufS } }, { binding: 5, resource: { buffer: bufO } },
771
+ { binding: 6, resource: { buffer: bufD } } ], m, n, batch, d.acc);
772
+ release(device, [bufX, bufW, bufR, bufS, bufD, r.bufO, r.read]);
773
+ return r.out;
774
+ }
775
+
776
+ async function gpuBgemmDP4(device, pipeline, Xq, Wq, rs, cs, d) {
777
+ const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
778
+ const kw = Math.ceil(k / 4);
779
+ const Xp = packRows(Xq, batch * m, k, kw); // rows are (batch·m)
780
+ const Wp = new Uint32Array(batch * n * kw); // per-batch Wᵀ, packed
781
+ for (let bz = 0; bz < batch; bz++) {
782
+ const wt = transposeI8(Wq.subarray(bz * k * n, (bz + 1) * k * n), k, n);
783
+ Wp.set(packRows(wt, n, k, kw), bz * n * kw);
784
+ }
785
+ const bufX = up(device, Xp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
786
+ const bufW = up(device, Wp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
787
+ const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
788
+ const bufS = up(device, cs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
789
+ const bufD = up(device, new Uint32Array([m, kw, n, flags]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
790
+ const r = await runBgPass(device, pipeline, (bufO) => [
791
+ { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
792
+ { binding: 2, resource: { buffer: bufR } }, { binding: 3, resource: { buffer: bufS } },
793
+ { binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], m, n, batch, d.acc);
794
+ release(device, [bufX, bufW, bufR, bufS, bufD, r.bufO, r.read]);
795
+ return r.out;
796
+ }
797
+
798
+ // B2B MLP chain: gemm1 (ReLU fused) + rowmax in one encoder, a 4·m-byte
799
+ // absmax readback, then quantize + gemm2 in a second encoder. h1 comes back
800
+ // because the STE backward needs it, but it never goes UP again — gemm2's
801
+ // left operand is produced and consumed entirely on the GPU.
802
+ async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
803
+ const { m, k, h, n } = d, dp4 = !!env.dp4;
804
+ const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
805
+ let bufX, bufW1, bufW2, kw1, hw;
806
+ if (dp4) {
807
+ kw1 = Math.ceil(k / 4); hw = Math.ceil(h / 4);
808
+ bufX = up(device, packRows(xq, m, k, kw1), SU);
809
+ bufW1 = up(device, packRows(transposeI8(w1q, k, h), h, k, kw1), SU);
810
+ bufW2 = up(device, packRows(transposeI8(w2q, h, n), n, h, hw), SU);
811
+ } else {
812
+ kw1 = k; hw = h;
813
+ bufX = up(device, Int32Array.from(xq), SU);
814
+ bufW1 = up(device, Int32Array.from(w1q), SU);
815
+ bufW2 = up(device, Int32Array.from(w2q), SU);
816
+ }
817
+ const bufRs = up(device, xs, SU), bufCs1 = up(device, w1s, SU), bufCs2 = up(device, w2s, SU);
818
+ const bufH = mk(device, m * h * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
819
+ // rowmax accumulates with atomicMax and NEEDS zeros. A fresh buffer was
820
+ // zero-initialized; a POOLED buffer is not — cleared in the encoder below.
821
+ const bufMX = mk(device, m * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST);
822
+ const UU = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST;
823
+ const bufD1 = up(device, new Uint32Array([m, kw1, h, 1]), UU); // flags=1: fused ReLU
824
+ const bufDM = up(device, new Uint32Array([m, h, 0, 0]), UU);
825
+ const gemmBind = (bufA, bufB, bufR, bufC, bufO, bufD) => device.createBindGroup({
826
+ layout: env.gemm.getBindGroupLayout(0),
827
+ entries: (dp4 ? [bufA, bufB, bufR, bufC, bufO, bufD]
828
+ : [bufA, bufB, env.lutBuf, bufR, bufC, bufO, bufD])
829
+ .map((b, i) => ({ binding: i, resource: { buffer: b } })) });
830
+ const enc1 = device.createCommandEncoder();
831
+ enc1.clearBuffer(bufMX, 0, m * 4); // pooled buffer: zero the atomicMax accumulator
832
+ let pass = enc1.beginComputePass();
833
+ pass.setPipeline(env.gemm);
834
+ pass.setBindGroup(0, gemmBind(bufX, bufW1, bufRs, bufCs1, bufH, bufD1));
835
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(h / 8), 1); pass.end();
836
+ pass = enc1.beginComputePass();
837
+ pass.setPipeline(env.rowmax);
838
+ pass.setBindGroup(0, device.createBindGroup({ layout: env.rowmax.getBindGroupLayout(0), entries: [
839
+ { binding: 0, resource: { buffer: bufH } }, { binding: 1, resource: { buffer: bufMX } },
840
+ { binding: 2, resource: { buffer: bufDM } } ] }));
841
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(h / 8), 1); pass.end();
842
+ const readH = mk(device, m * h * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
843
+ const readM = mk(device, m * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
844
+ enc1.copyBufferToBuffer(bufH, 0, readH, 0, m * h * 4);
845
+ enc1.copyBufferToBuffer(bufMX, 0, readM, 0, m * 4);
846
+ device.queue.submit([enc1.finish()]);
847
+ await Promise.all([readH.mapAsync(GPUMapMode.READ), readM.mapAsync(GPUMapMode.READ)]);
848
+ const h1 = new Float32Array(readH.getMappedRange(0, m * h * 4).slice(0)); readH.unmap();
849
+ // the atomicMax'ed u32 bit patterns ARE the f32 |max| values
850
+ const mx = new Float32Array(readM.getMappedRange(0, m * 4).slice(0)); readM.unmap();
851
+ // scale derivation in JS f64 — exactly rounded, identical on every device
852
+ // (WGSL division is 2.5 ULP, which is why it never runs on the GPU)
853
+ const sc = root.Verified.scalesFromAbsMax(mx);
854
+ const bufInv = up(device, sc.inv, SU), bufHs = up(device, sc.scale, SU);
855
+ const bufQ = mk(device, m * hw * 4, GPUBufferUsage.STORAGE);
856
+ const bufDQ = up(device, new Uint32Array([m, h, hw, 0]), UU);
857
+ const bufD2 = up(device, new Uint32Array([m, hw, n, 0]), UU);
858
+ const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
859
+ const enc2 = device.createCommandEncoder();
860
+ pass = enc2.beginComputePass();
861
+ pass.setPipeline(env.quant);
862
+ pass.setBindGroup(0, device.createBindGroup({ layout: env.quant.getBindGroupLayout(0), entries: [
863
+ { binding: 0, resource: { buffer: bufH } }, { binding: 1, resource: { buffer: bufInv } },
864
+ { binding: 2, resource: { buffer: bufQ } }, { binding: 3, resource: { buffer: bufDQ } } ] }));
865
+ pass.dispatchWorkgroups(Math.ceil((m * (dp4 ? hw : h)) / 64)); pass.end();
866
+ pass = enc2.beginComputePass();
867
+ pass.setPipeline(env.gemm);
868
+ pass.setBindGroup(0, gemmBind(bufQ, bufW2, bufHs, bufCs2, bufO, bufD2));
869
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), 1); pass.end();
870
+ const readO = mk(device, m * n * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
871
+ enc2.copyBufferToBuffer(bufO, 0, readO, 0, m * n * 4);
872
+ device.queue.submit([enc2.finish()]);
873
+ await readO.mapAsync(GPUMapMode.READ);
874
+ const out = new Float32Array(readO.getMappedRange(0, m * n * 4).slice(0)); readO.unmap();
875
+ release(device, [bufX, bufW1, bufW2, bufRs, bufCs1, bufCs2, bufH, bufMX, bufD1, bufDM,
876
+ bufInv, bufHs, bufQ, bufDQ, bufD2, bufO, readH, readM, readO]);
877
+ return { h1, out };
878
+ }
879
+
880
+ // ---- buffer pool ------------------------------------------------------------
881
+ // Every dispatch used to create and destroy its buffers — ~19 create/destroy
882
+ // pairs per MLP chain call, per layer, per step. Creation cost is pure driver
883
+ // overhead, and at these GEMM sizes it is a large share of the step. The pool
884
+ // recycles buffers by (usage, size-bucket): same bytes uploaded, same regions
885
+ // read, ZERO math change — the kernels only ever index inside the logical
886
+ // dims, so the stale tail of a bucketed buffer is never read. The one
887
+ // exception is a buffer a kernel expects ZEROED (the rowmax accumulator):
888
+ // pooled buffers are NOT fresh, so that one is cleared explicitly in the
889
+ // encoder (see gpuMlpChain). Correctness is enforced where it always was:
890
+ // the exact init gates, the live audit, and the per-step probe hash all run
891
+ // through these pooled paths.
892
+ const _pools = new WeakMap(); // device -> Map(key -> free list)
893
+ function _poolOf(device) {
894
+ let p = _pools.get(device);
895
+ if (!p) { p = new Map(); _pools.set(device, p); }
896
+ return p;
897
+ }
898
+ function mk(device, size, usage) {
899
+ let cap = 256; while (cap < size) cap *= 2;
900
+ const key = usage + ":" + cap;
901
+ const list = _poolOf(device).get(key);
902
+ if (list && list.length) return list.pop();
903
+ const b = device.createBuffer({ size: cap, usage });
904
+ b._poolKey = key;
905
+ return b;
906
+ }
907
+ function up(device, arr, usage) {
908
+ const b = mk(device, Math.max(16, arr.byteLength), usage);
909
+ device.queue.writeBuffer(b, 0, arr);
910
+ return b;
911
+ }
912
+ function release(device, bufs) {
913
+ const p = _poolOf(device);
914
+ for (const b of bufs) {
915
+ if (!b || !b._poolKey) { if (b) b.destroy(); continue; }
916
+ let list = p.get(b._poolKey);
917
+ if (!list) { list = []; p.set(b._poolKey, list); }
918
+ if (list.length < 64) list.push(b); else b.destroy();
919
+ }
920
+ }
921
+
922
+ // ---- canonical kernel probe -------------------------------------------------
923
+ // The weight hash CANNOT catch a device whose kernel is wrong: weights only
924
+ // depend on the gradient bytes everyone receives, so a fleet averaging one
925
+ // device's bad gradient stays bit-identical and perfectly happy. This is the
926
+ // check that can. Every device runs the SAME seeded int8 GEMM through its own
927
+ // live kernel (verify variant -> raw int32 accumulator, exact on every
928
+ // backend — GPU DP4A, GPU LUT, CPU mirror alike) and hashes the result. Same
929
+ // input + correct kernels => same hash, regardless of hardware. A device that
930
+ // disagrees is computing different arithmetic than the rest of the fleet.
931
+ const PROBE = { m: 24, k: 96, n: 24, batch: 2 };
932
+ function probeInputs() {
933
+ let seed = 0x5EED; // fixed: identical on every device
934
+ const rnd = () => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
935
+ const { m, k, n, batch } = PROBE;
936
+ const Xq = new Int8Array(batch * m * k), Wq = new Int8Array(batch * k * n);
937
+ for (let i = 0; i < Xq.length; i++) Xq[i] = Math.round(rnd() * 254 - 127);
938
+ for (let i = 0; i < Wq.length; i++) Wq[i] = Math.round(rnd() * 254 - 127);
939
+ const rs = Float32Array.from({ length: batch * m }, () => 1);
940
+ const cs = Float32Array.from({ length: batch * n }, () => 1);
941
+ return { Xq, Wq, rs, cs, d: { ...PROBE, acc: true } };
942
+ }
943
+ // ---- transcendental probe ---------------------------------------------------
944
+ // The kernel probe covers int8 GEMM arithmetic. It does NOT cover the JS
945
+ // transcendentals, and ECMA-262 does not require Math.exp/log/cos/sin to be
946
+ // correctly rounded — it calls them "implementation-approximated", so V8,
947
+ // SpiderMonkey and JSC are all permitted to return different bits.
948
+ //
949
+ // One use of them can fork a fleet: weight INIT. Peers build their own
950
+ // starting weights from a shared seed rather than broadcasting them
951
+ // (Box-Muller, so Math.log/cos/sin), and one ulp of engine disagreement
952
+ // means peers begin from different models. The per-step uses (softmax, loss)
953
+ // cannot fork anything — every peer averages the same received gradient
954
+ // BYTES, so a differently-rounded local gradient is still a gradient the
955
+ // whole group then agrees on.
956
+ //
957
+ // This hashes the exact f64 bits of those four functions over a fixed grid,
958
+ // so a browser whose math library differs is IDENTIFIED instead of showing
959
+ // up later as an unexplained weight-hash mismatch. Cost: microseconds, once.
960
+ // Detection, not correction — pinning the algorithms would be a fleet-wide
961
+ // numerics change, and is only worth doing if this ever reports a mismatch.
962
+ function mathProbe() {
963
+ const buf = new ArrayBuffer(8), dv = new DataView(buf);
964
+ let h = 0x811c9dc5;
965
+ const eat = (x) => {
966
+ dv.setFloat64(0, x);
967
+ for (let i = 0; i < 8; i++) { h ^= dv.getUint8(i); h = Math.imul(h, 0x01000193); }
968
+ };
969
+ for (let i = 1; i <= 400; i++) {
970
+ const u = i / 401; // (0,1): the Box-Muller domain
971
+ eat(Math.log(u));
972
+ eat(Math.cos(2 * Math.PI * u));
973
+ eat(Math.sin(2 * Math.PI * u));
974
+ eat(Math.sqrt(-2 * Math.log(u))); // the exact composite randn uses
975
+ eat(Math.exp(-u * 12)); // the softmax domain
976
+ }
977
+ return h >>> 0;
978
+ }
979
+
980
+ async function kernelProbe(compute, L) {
981
+ const { Xq, Wq, rs, cs, d } = probeInputs();
982
+ const out = compute && compute.bgemm
983
+ ? await compute.bgemm(Xq, Wq, rs, cs, d)
984
+ : root.Verified.bgemmJS(Xq, Wq, rs, cs, d, L);
985
+ let h = 0x811c9dc5; // FNV-1a over the exact int32 results
986
+ const b = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
987
+ for (let i = 0; i < b.length; i++) { h ^= b[i]; h = Math.imul(h, 0x01000193); }
988
+ // fold in the transcendental hash: same question ("is your arithmetic the
989
+ // same as mine?"), same broadcast slot, no wire-format change
990
+ const mh = mathProbe();
991
+ for (let i = 0; i < 4; i++) { h ^= (mh >>> (i * 8)) & 0xFF; h = Math.imul(h, 0x01000193); }
992
+ return h >>> 0;
993
+ }
994
+
995
+ root.Compute = { initCompute, loadLUTs, kernelProbe, mathProbe };
996
+ })(self);
public/wire.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The wire protocol, as pure functions.
2
+ //
3
+ // Kept out of app.js on purpose: a codec that only exists inside a browser
4
+ // event handler cannot be tested, and the failures this protocol can have are
5
+ // precisely the ones that are invisible at runtime. A misread offset does not
6
+ // throw — it yields a plausible number, and a plausible number in an
7
+ // activation is a slightly wrong sentence nobody can attribute to anything.
8
+ // So the codec lives here and test_wire.js round-trips it.
9
+ //
10
+ // Sentinels continue DaisyChain-Web's numbering (it uses -2..-8), so a client
11
+ // pointed at the wrong server sees an unknown tag rather than a valid-looking
12
+ // message of the wrong kind.
13
+ (function (root) {
14
+ "use strict";
15
+
16
+ const FRAG = -5, // fragment of a large message (shared with DaisyChain-Web)
17
+ HELLO = -20, // capability report
18
+ ASSIGN = -21, // model identity + which layers you own (no weights)
19
+ READY = -22, // a stage has fetched its layers and is in the ring
20
+ ACT = -23, // hidden state moving to the next stage
21
+ TOKEN = -24, // a token was emitted
22
+ DONE = -25; // run finished
23
+
24
+ // The address of the head's return leg. Not a stage index: the head is both
25
+ // stage 0 and the terminus, so "next = 0" is ambiguous — outbound it means
26
+ // "stage 0, run your blocks", inbound it means "the lap is done". They need
27
+ // distinct addresses or the head re-runs its own blocks and the lap never
28
+ // closes. Negative, so it can never collide with a real stage index.
29
+ const RETURN = -1;
30
+
31
+ const enc = new TextEncoder(), dec = new TextDecoder();
32
+
33
+ function tagOf(buf) { return new Int32Array(buf, 0, 1)[0]; }
34
+
35
+ // ---- hello -----------------------------------------------------------------
36
+ function packHello(capacity, probeHash, backend) {
37
+ const nb = enc.encode(String(backend || "?").slice(0, 32));
38
+ const buf = new ArrayBuffer(16 + nb.length);
39
+ new Int32Array(buf, 0, 1)[0] = HELLO;
40
+ new Float32Array(buf, 4, 1)[0] = capacity;
41
+ new Uint32Array(buf, 8, 1)[0] = probeHash >>> 0;
42
+ new Int32Array(buf, 12, 1)[0] = nb.length;
43
+ new Uint8Array(buf, 16).set(nb);
44
+ return buf;
45
+ }
46
+ function unpackHello(buf) {
47
+ const n = new Int32Array(buf, 12, 1)[0];
48
+ if (n < 0 || 16 + n > buf.byteLength) throw new Error("hello: bad backend length");
49
+ return { capacity: new Float32Array(buf, 4, 1)[0],
50
+ probeHash: new Uint32Array(buf, 8, 1)[0],
51
+ backend: dec.decode(new Uint8Array(buf, 16, n)) };
52
+ }
53
+
54
+ // ---- assignment -------------------------------------------------------------
55
+ // Weights do NOT travel between peers. The head sends each device the model's
56
+ // identity and which layers it owns; the device then fetches exactly those
57
+ // tensors from the Hub itself, with its own credentials. So this message is
58
+ // small, and a token never crosses the wire.
59
+ //
60
+ // [i32 ASSIGN][utf8 JSON]
61
+ //
62
+ // JSON rather than packed ints because the payload is a model spec whose
63
+ // fields differ by architecture. It is validated on arrival — a malformed
64
+ // assignment must fail here, not three layers deep in a GEMM.
65
+ function packAssign(a) {
66
+ const bytes = enc.encode(JSON.stringify(a));
67
+ const buf = new ArrayBuffer(4 + bytes.length);
68
+ new Int32Array(buf, 0, 1)[0] = ASSIGN;
69
+ new Uint8Array(buf, 4).set(bytes);
70
+ return buf;
71
+ }
72
+ function unpackAssign(buf) {
73
+ let a;
74
+ try { a = JSON.parse(dec.decode(new Uint8Array(buf, 4))); }
75
+ catch (e) { throw new Error("assignment: payload is not valid JSON"); }
76
+ if (!a || typeof a.repo !== "string" || !a.repo)
77
+ throw new Error("assignment: no repo id");
78
+ if (!/^[\w.-]+\/[\w.-]+$/.test(a.repo))
79
+ throw new Error(`assignment: "${a.repo}" is not a valid repo id`);
80
+ if (!a.spec || !a.spec.layers || !a.spec.hidden)
81
+ throw new Error("assignment: incomplete model spec");
82
+ if (!Array.isArray(a.plan) || !a.plan.length)
83
+ throw new Error("assignment: no plan");
84
+ // A plan that does not cover every layer exactly once still generates
85
+ // fluent text — with a layer missing. It has to be refused at the door.
86
+ let cover = 0;
87
+ for (const s of a.plan) {
88
+ if (typeof s.lo !== "number" || typeof s.hi !== "number" || s.hi < s.lo || s.lo < 0 || s.hi > a.spec.layers)
89
+ throw new Error("assignment: stage range out of bounds");
90
+ cover += s.hi - s.lo;
91
+ }
92
+ if (cover !== a.spec.layers)
93
+ throw new Error(`assignment: stages cover ${cover} of ${a.spec.layers} layers`);
94
+ if (!a.plan[0].head) throw new Error("assignment: stage 0 must be the head");
95
+ if (typeof a.mine !== "number" || a.mine < 0 || a.mine >= a.plan.length)
96
+ throw new Error("assignment: no valid stage index for this device");
97
+ return a;
98
+ }
99
+
100
+ // ---- activation ------------------------------------------------------------
101
+ // [i32 ACT, seq, tokenIdx, nextIndex][u32 actHash, modelHash][f32 hidden]
102
+ // actHash is an integrity check on the payload; modelHash answers a different
103
+ // question — whether this activation belongs to the model I hold a slice of.
104
+ // Two rings running at once on one device is not hypothetical: reload the
105
+ // head with a different checkpoint and the old stages are still out there.
106
+ function packAct(seq, tokenIdx, nextIndex, hidden, hashF32, modelHash) {
107
+ const buf = new ArrayBuffer(24 + hidden.byteLength);
108
+ new Int32Array(buf, 0, 4).set([ACT, seq, tokenIdx, nextIndex]);
109
+ new Uint32Array(buf, 16, 2).set([hashF32(hidden) >>> 0, modelHash >>> 0]);
110
+ new Float32Array(buf, 24).set(hidden);
111
+ return buf;
112
+ }
113
+ function unpackAct(buf, hashF32) {
114
+ if (buf.byteLength < 24) throw new Error("act: truncated header");
115
+ const iv = new Int32Array(buf, 0, 4);
116
+ const [actHash, modelHash] = new Uint32Array(buf, 16, 2);
117
+ const hidden = new Float32Array(buf.slice(24));
118
+ if (hashF32 && (hashF32(hidden) >>> 0) !== actHash) throw new Error("act: payload failed its integrity hash");
119
+ return { seq: iv[1], tokenIdx: iv[2], nextIndex: iv[3], actHash, modelHash, hidden };
120
+ }
121
+
122
+ // ---- routing ---------------------------------------------------------------
123
+ // Where an activation goes after this stage, and how to read one that arrives.
124
+ // These are two lines each and they live here, as pure functions, only
125
+ // because getting them wrong is invisible everywhere else: the math is
126
+ // correct, the bytes are correct, and the lap simply never closes. Neither a
127
+ // numeric oracle nor a codec round-trip can see that — it is a property of
128
+ // the ROUTE, so it needs something that can walk one.
129
+ function routeAfter(plan, myIndex) {
130
+ const isLast = myIndex === plan.length - 1;
131
+ const next = isLast ? plan[0] : plan[myIndex + 1];
132
+ return { to: next.id, address: isLast ? RETURN : next.index, isReturn: isLast };
133
+ }
134
+ function classifyAct(nextIndex, myIndex) {
135
+ if (nextIndex === RETURN) return "return"; // the lap is finished
136
+ if (nextIndex === myIndex) return "mine"; // run my blocks, pass it on
137
+ return "other"; // not addressed to me
138
+ }
139
+
140
+ // ---- token / done ----------------------------------------------------------
141
+ function packToken(seq, id, total) {
142
+ const buf = new ArrayBuffer(16);
143
+ new Int32Array(buf).set([TOKEN, seq, id, total]);
144
+ return buf;
145
+ }
146
+ function unpackToken(buf) {
147
+ const iv = new Int32Array(buf, 0, 4);
148
+ return { seq: iv[1], id: iv[2], total: iv[3] };
149
+ }
150
+ function packDone(seq) { return new Int32Array([DONE, seq]).buffer; }
151
+
152
+ // ---- ready ------------------------------------------------------------------
153
+ function packReady(stageIndex, ok, note) {
154
+ const bytes = enc.encode(JSON.stringify({ stageIndex, ok: !!ok, note: String(note || "").slice(0, 300) }));
155
+ const buf = new ArrayBuffer(4 + bytes.length);
156
+ new Int32Array(buf, 0, 1)[0] = READY;
157
+ new Uint8Array(buf, 4).set(bytes);
158
+ return buf;
159
+ }
160
+ function unpackReady(buf) {
161
+ try { return JSON.parse(dec.decode(new Uint8Array(buf, 4))); }
162
+ catch (e) { throw new Error("ready: payload is not valid JSON"); }
163
+ }
164
+
165
+ const api = { FRAG, HELLO, ASSIGN, READY, ACT, TOKEN, DONE, RETURN, tagOf,
166
+ packHello, unpackHello, packAssign, unpackAssign, packReady, unpackReady,
167
+ packAct, unpackAct, packToken, unpackToken, packDone,
168
+ routeAfter, classifyAct };
169
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
170
+ else root.Wire = api;
171
+ })(typeof self !== "undefined" ? self : this);
server.js ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DaisyChain-Infer signaling + static host.
2
+ //
3
+ // Deliberately tiny. What it does NOT do is the point: it introduces peers and
4
+ // relays WebRTC handshakes, and never sees a weight, an activation, a prompt,
5
+ // or a generated token. Those move directly between the devices in the ring,
6
+ // and weights come from the Hugging Face CDN straight to each browser.
7
+ //
8
+ // The one exception is the OAuth code exchange below, which by construction
9
+ // has to handle an access token for a few milliseconds. That is called out
10
+ // rather than glossed: see the comment on /auth/callback.
11
+ //
12
+ // Only dependency: `ws`. Run: npm install && npm start
13
+ const http = require("http");
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const crypto = require("crypto");
17
+ const { WebSocketServer } = require("ws");
18
+
19
+ // ---- rooms -------------------------------------------------------------------
20
+ // Snapdrop-style grouping: same public IP -> same room, so devices on your own
21
+ // network find each other with no code. ?room=CODE crosses networks, gated by
22
+ // host approval.
23
+ function clientIP(req) {
24
+ const xff = req.headers["x-forwarded-for"];
25
+ if (xff) return xff.split(",")[0].trim();
26
+ return (req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
27
+ }
28
+ function roomFor(req) {
29
+ const u = new URL(req.url, "http://x");
30
+ const code = u.searchParams.get("room");
31
+ if (code) return "room:" + code;
32
+ const h = crypto.createHash("sha256").update(clientIP(req)).digest("hex").slice(0, 10);
33
+ return "net:" + h;
34
+ }
35
+
36
+ const PORT = process.env.PORT || 8788;
37
+ const PUB = path.join(__dirname, "public");
38
+ const TYPES = { ".html": "text/html", ".js": "text/javascript",
39
+ ".css": "text/css", ".json": "application/json" };
40
+
41
+ // ---- OAuth (hosted deployments) ----------------------------------------------
42
+ // On a Hugging Face Space with `hf_oauth: true`, these are injected by the
43
+ // platform. When they are absent — a local run — OAuth is simply off and the
44
+ // page falls back to asking for a token directly, which is safe there because
45
+ // the page is served from the user's own machine.
46
+ const OA = {
47
+ clientId: process.env.OAUTH_CLIENT_ID,
48
+ clientSecret: process.env.OAUTH_CLIENT_SECRET,
49
+ scopes: process.env.OAUTH_SCOPES || "read-repos",
50
+ provider: (process.env.OPENID_PROVIDER_URL || "https://huggingface.co").replace(/\/$/, ""),
51
+ spaceHost: process.env.SPACE_HOST || "",
52
+ };
53
+ const oauthOn = !!(OA.clientId && OA.clientSecret);
54
+
55
+ // One-time `state` values, so a callback cannot be replayed or forged. Short
56
+ // TTL, and consumed on use.
57
+ const states = new Map(); // state -> expiry ms
58
+ function newState() {
59
+ const s = crypto.randomBytes(24).toString("base64url");
60
+ states.set(s, Date.now() + 10 * 60 * 1000);
61
+ return s;
62
+ }
63
+ function takeState(s) {
64
+ const exp = states.get(s);
65
+ if (exp === undefined) return false;
66
+ states.delete(s); // single use
67
+ return exp > Date.now();
68
+ }
69
+ setInterval(() => {
70
+ const now = Date.now();
71
+ for (const [s, exp] of states) if (exp <= now) states.delete(s);
72
+ }, 60000);
73
+
74
+ function redirectUri(req) {
75
+ const host = OA.spaceHost || req.headers.host;
76
+ const proto = OA.spaceHost || /^(localhost|127\.|\[::1\])/.test(host || "") === false ? "https" : "http";
77
+ return `${proto}://${host}/auth/callback`;
78
+ }
79
+
80
+ function oauthLogin(req, res) {
81
+ const state = newState();
82
+ const u = new URL(OA.provider + "/oauth/authorize");
83
+ u.searchParams.set("client_id", OA.clientId);
84
+ u.searchParams.set("redirect_uri", redirectUri(req));
85
+ u.searchParams.set("response_type", "code");
86
+ u.searchParams.set("scope", OA.scopes);
87
+ u.searchParams.set("state", state);
88
+ res.writeHead(302, { Location: u.toString() });
89
+ res.end();
90
+ }
91
+
92
+ // The token exchange needs the client secret, so it MUST happen here rather
93
+ // than in the browser — which means this server briefly holds an access token.
94
+ // It is never written to disk, never logged, and never kept: it is handed to
95
+ // the browser in the URL *fragment*, which browsers do not send to servers and
96
+ // which the page strips from history immediately on arrival. That is the
97
+ // smallest exposure the OAuth flow allows; it is not zero, and the Space's
98
+ // README says so.
99
+ async function oauthCallback(req, res, url) {
100
+ const code = url.searchParams.get("code");
101
+ const state = url.searchParams.get("state");
102
+ const err = url.searchParams.get("error");
103
+ const bail = (msg) => {
104
+ res.writeHead(302, { Location: "/#oauth_error=" + encodeURIComponent(msg) });
105
+ res.end();
106
+ };
107
+ if (err) return bail(err);
108
+ if (!code || !state) return bail("missing code or state");
109
+ if (!takeState(state)) return bail("state was not recognised — start the sign-in again");
110
+ try {
111
+ const body = new URLSearchParams({
112
+ grant_type: "authorization_code",
113
+ code,
114
+ redirect_uri: redirectUri(req),
115
+ client_id: OA.clientId,
116
+ client_secret: OA.clientSecret,
117
+ });
118
+ const r = await fetch(OA.provider + "/oauth/token", {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
121
+ body,
122
+ });
123
+ if (!r.ok) return bail(`token exchange failed (HTTP ${r.status})`);
124
+ const j = await r.json();
125
+ if (!j.access_token) return bail("no access token in the response");
126
+ res.writeHead(302, { Location: "/#hf=" + encodeURIComponent(j.access_token) });
127
+ res.end();
128
+ } catch (e) {
129
+ bail("token exchange error"); // deliberately not e.message
130
+ }
131
+ }
132
+
133
+ // keepalive: proxies close idle WebSockets, and a ring can sit idle between
134
+ // prompts far longer than a training run ever does
135
+ setInterval(() => {
136
+ for (const room of rooms.values())
137
+ for (const [, v] of room.peers) {
138
+ if (v.ws.isAlive === false) { v.ws.terminate(); continue; }
139
+ v.ws.isAlive = false;
140
+ send(v.ws, { type: "ping" });
141
+ }
142
+ }, 30000);
143
+
144
+ const server = http.createServer((req, res) => {
145
+ const url = new URL(req.url, "http://x");
146
+ let p = decodeURIComponent(url.pathname);
147
+
148
+ if (p === "/mode") {
149
+ let rtc = null;
150
+ try { if (process.env.DAISY_RTC_CONFIG) rtc = JSON.parse(process.env.DAISY_RTC_CONFIG); } catch (e) {}
151
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
152
+ return res.end(JSON.stringify({
153
+ // DAISY_FORCE_ROOMS=1 (set on the hosted Space): no LAN auto-grouping, so
154
+ // strangers who happen to share a public IP — same CGNAT, same campus,
155
+ // same office — are never put in one ring. Each visitor makes their own
156
+ // room and invites devices by link.
157
+ forceRooms: !!process.env.DAISY_FORCE_ROOMS,
158
+ // When OAuth is available the page does not offer a paste-a-token box at
159
+ // all: on a deployment the user does not control, signing in is the only
160
+ // credential path offered.
161
+ oauth: oauthOn,
162
+ rtc,
163
+ }));
164
+ }
165
+ if (p === "/auth/login" || p === "/oauth/login") {
166
+ if (!oauthOn) { res.writeHead(404); return res.end("oauth is not configured"); }
167
+ return oauthLogin(req, res);
168
+ }
169
+ // HF registers /auth/callback for Spaces; the alias costs nothing and saves a
170
+ // broken deploy if the platform ever hands back the other spelling.
171
+ if (p === "/auth/callback" || p === "/oauth/callback") {
172
+ if (!oauthOn) { res.writeHead(404); return res.end("oauth is not configured"); }
173
+ return void oauthCallback(req, res, url);
174
+ }
175
+
176
+ if (p === "/") p = "/index.html";
177
+ const file = path.join(PUB, path.normalize(p));
178
+ if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
179
+ fs.readFile(file, (err, data) => {
180
+ if (err) { res.writeHead(404); return res.end("not found"); }
181
+ res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] || "application/octet-stream" });
182
+ res.end(data);
183
+ });
184
+ });
185
+
186
+ const wss = new WebSocketServer({ server });
187
+ const rooms = new Map();
188
+ let nextId = 1;
189
+ function send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); }
190
+
191
+ wss.on("connection", (ws, req) => {
192
+ const roomId = roomFor(req);
193
+ const name = (new URL(req.url, "http://x").searchParams.get("name") || "").slice(0, 40) || ("p" + nextId);
194
+ const id = "p" + (nextId++);
195
+ ws.peerId = id; ws.roomId = roomId;
196
+ if (!rooms.has(roomId)) rooms.set(roomId, { peers: new Map(), host: null, pending: new Map() });
197
+ const room = rooms.get(roomId);
198
+ const isPrivate = roomId.startsWith("room:");
199
+
200
+ function admit(pid, peer) {
201
+ const roster = [...room.peers.entries()].map(([qid, v]) => ({ id: qid, name: v.name }));
202
+ send(peer.ws, { type: "welcome", id: pid, room: roomId, peers: roster, host: room.host === pid });
203
+ for (const [, v] of room.peers) send(v.ws, { type: "peer-joined", id: pid, name: peer.name });
204
+ room.peers.set(pid, peer);
205
+ }
206
+
207
+ if (isPrivate && room.peers.size === 0) room.host = id;
208
+ if (isPrivate && room.host !== id) {
209
+ room.pending.set(id, { ws, name });
210
+ send(ws, { type: "waiting" });
211
+ const h = room.peers.get(room.host);
212
+ if (h) send(h.ws, { type: "join-request", id, name });
213
+ } else {
214
+ admit(id, { ws, name });
215
+ }
216
+
217
+ ws.on("message", (buf) => {
218
+ let msg; try { msg = JSON.parse(buf); } catch { return; }
219
+ if (msg.type === "signal" && msg.to && room.peers.has(id)) {
220
+ const target = room.peers.get(msg.to);
221
+ if (target) send(target.ws, { type: "signal", from: id, data: msg.data });
222
+ } else if (msg.type === "pong") {
223
+ ws.isAlive = true;
224
+ } else if (msg.type === "relay" && msg.to && room.peers.has(id)) {
225
+ // last-resort path when two devices cannot form a direct WebRTC route.
226
+ // Note this DOES put activations through the server — the UI says so.
227
+ const target = room.peers.get(msg.to);
228
+ if (target) send(target.ws, { type: "relay", from: id, data: msg.data });
229
+ } else if (msg.type === "admit" && id === room.host) {
230
+ const p = room.pending.get(msg.id);
231
+ if (!p) return;
232
+ room.pending.delete(msg.id);
233
+ if (msg.allow) admit(msg.id, p);
234
+ else { send(p.ws, { type: "denied" }); p.ws.close(); }
235
+ }
236
+ });
237
+
238
+ ws.on("close", () => {
239
+ room.pending.delete(id);
240
+ if (room.peers.delete(id))
241
+ for (const [, v] of room.peers) send(v.ws, { type: "peer-left", id });
242
+ if (room.host === id) {
243
+ room.host = room.peers.keys().next().value ?? null;
244
+ const h = room.peers.get(room.host);
245
+ if (h) {
246
+ send(h.ws, { type: "host" });
247
+ for (const [pid, p] of room.pending) send(h.ws, { type: "join-request", id: pid, name: p.name });
248
+ }
249
+ }
250
+ if (room.peers.size === 0 && room.pending.size === 0) rooms.delete(roomId);
251
+ });
252
+ });
253
+
254
+ server.listen(PORT, () =>
255
+ console.log(`DaisyChain-Infer on http://localhost:${PORT}` +
256
+ (oauthOn ? " (HF OAuth enabled)" : "") +
257
+ (process.env.DAISY_FORCE_ROOMS ? " (rooms forced)" : "")));
test_loader.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The loader: safetensors parsing, dtype widening, range coalescing, and BPE.
2
+ //
3
+ // This is the layer where "load any model off HuggingFace" is either true or
4
+ // quietly false. Every failure here is silent by nature — a misread offset
5
+ // gives plausible floats, a wrong dtype gives plausible floats, a
6
+ // half-implemented tokenizer gives plausible text. So the checks are exact,
7
+ // and several are built by CONSTRUCTING a file and reading it back rather than
8
+ // by trusting a fixture.
9
+ //
10
+ // node test_loader.js
11
+ const assert = require("assert");
12
+ const ST = require("./public/safetensors.js");
13
+ const Tok = require("./public/tokenizer.js");
14
+ const Arch = require("./public/arch.js");
15
+
16
+ let failed = 0;
17
+ function t(name, fn) {
18
+ try { fn(); console.log(` ok ${name}`); }
19
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
20
+ }
21
+
22
+ console.log("\nDaisyChain-Infer — loader\n");
23
+
24
+ // Build a real safetensors buffer so the parser is tested against the format,
25
+ // not against our own idea of it.
26
+ function buildFile(entries) {
27
+ let offset = 0;
28
+ const header = {};
29
+ const chunks = [];
30
+ for (const [name, dtype, shape, bytes] of entries) {
31
+ header[name] = { dtype, shape, data_offsets: [offset, offset + bytes.length] };
32
+ chunks.push(bytes);
33
+ offset += bytes.length;
34
+ }
35
+ const hj = Buffer.from(JSON.stringify(header), "utf8");
36
+ const out = Buffer.alloc(8 + hj.length + offset);
37
+ out.writeUInt32LE(hj.length, 0); out.writeUInt32LE(0, 4);
38
+ hj.copy(out, 8);
39
+ let o = 8 + hj.length;
40
+ for (const c of chunks) { Buffer.from(c).copy(out, o); o += c.length; }
41
+ return out.buffer.slice(out.byteOffset, out.byteOffset + out.length);
42
+ }
43
+
44
+ t("parses a header and locates every tensor", () => {
45
+ const a = Buffer.from(new Float32Array([1, 2, 3, 4]).buffer);
46
+ const b = Buffer.from(new Float32Array([5, 6]).buffer);
47
+ const buf = buildFile([["w.a", "F32", [2, 2], a], ["w.b", "F32", [2], b]]);
48
+ const { tensors } = ST.parseHeader(buf);
49
+ assert.strictEqual(tensors.size, 2);
50
+ const ta = tensors.get("w.a");
51
+ assert.deepStrictEqual(ta.shape, [2, 2]);
52
+ assert.strictEqual(ta.elems, 4);
53
+ assert.deepStrictEqual([...ST.toF32("F32", new Uint8Array(buf, ta.start, ta.bytes))], [1, 2, 3, 4]);
54
+ const tb = tensors.get("w.b");
55
+ assert.deepStrictEqual([...ST.toF32("F32", new Uint8Array(buf, tb.start, tb.bytes))], [5, 6]);
56
+ });
57
+
58
+ // A shape that disagrees with the byte range means every subsequent tensor
59
+ // would be read at the wrong offset — plausible floats all the way down.
60
+ t("a header whose shape contradicts its byte range is refused", () => {
61
+ const a = Buffer.from(new Float32Array([1, 2, 3, 4]).buffer);
62
+ const buf = buildFile([["w", "F32", [9, 9], a]]);
63
+ assert.throws(() => ST.parseHeader(buf), /expected/);
64
+ });
65
+
66
+ t("junk in place of a header is refused, not guessed at", () => {
67
+ const junk = Buffer.alloc(64); junk.writeUInt32LE(16, 0);
68
+ assert.throws(() => ST.parseHeader(junk.buffer.slice(0, 64)), /not valid JSON|not a safetensors/);
69
+ const big = Buffer.alloc(16); big.writeUInt32LE(0, 0); big.writeUInt32LE(7, 4);
70
+ assert.throws(() => ST.parseHeader(big.buffer.slice(0, 16)), /implausibly large/);
71
+ });
72
+
73
+ // BF16 is the top 16 bits of an f32, so widening must be EXACT — not close.
74
+ t("BF16 widens exactly (it is the high half of an f32)", () => {
75
+ const vals = [1, -1, 0.5, -0.5, 2, 100, -0.0078125, 0];
76
+ const u16 = new Uint16Array(vals.length);
77
+ const f = new Float32Array(1), u = new Uint32Array(f.buffer);
78
+ for (let i = 0; i < vals.length; i++) { f[0] = vals[i]; u16[i] = u[0] >>> 16; }
79
+ const out = ST.bf16ToF32(u16, new Float32Array(vals.length));
80
+ for (let i = 0; i < vals.length; i++)
81
+ assert.strictEqual(out[i], vals[i], `bf16 round-trip changed ${vals[i]} to ${out[i]}`);
82
+ });
83
+
84
+ t("F16 widens exactly, including subnormals and signed zero", () => {
85
+ // every f16 has an exact f32 value, so this is a widening with no error
86
+ const cases = [[0x3C00, 1], [0xBC00, -1], [0x3800, 0.5], [0x0000, 0], [0x8000, -0],
87
+ [0x0001, Math.pow(2, -24)], [0x7BFF, 65504]];
88
+ const u16 = Uint16Array.from(cases.map(c => c[0]));
89
+ const out = ST.f16ToF32(u16, new Float32Array(cases.length));
90
+ cases.forEach(([, want], i) =>
91
+ assert.ok(Object.is(out[i], want), `f16 ${i}: got ${out[i]}, want ${want}`));
92
+ });
93
+
94
+ // Coalescing is a bandwidth optimisation, and a wrong one would hand a tensor
95
+ // the wrong bytes. It must merge only what is genuinely adjacent, and the
96
+ // per-tensor offsets must still land correctly inside a merged run.
97
+ t("range coalescing merges neighbours and keeps offsets correct", () => {
98
+ const mk = (start, end) => ({ start, end, bytes: end - start, elems: (end - start) / 4, dtype: "F32", name: `t${start}` });
99
+ const runs = ST.coalesce([mk(0, 100), mk(100, 200), mk(10_000_000, 10_000_100)], 1024);
100
+ assert.strictEqual(runs.length, 2, "adjacent ranges were not merged, or a distant one was");
101
+ assert.deepStrictEqual([runs[0].start, runs[0].end], [0, 200]);
102
+ assert.strictEqual(runs[0].tensors.length, 2);
103
+ assert.deepStrictEqual([runs[1].start, runs[1].end], [10_000_000, 10_000_100]);
104
+ // a tensor's data must be recoverable from its offset within the merged run
105
+ const t2 = runs[0].tensors[1];
106
+ assert.strictEqual(t2.start - runs[0].start, 100);
107
+ });
108
+
109
+ t("stage byte cost is computed from the header alone", () => {
110
+ const spec = Arch.fromConfig({ model_type: "llama", hidden_size: 64, num_hidden_layers: 4,
111
+ num_attention_heads: 4, num_key_value_heads: 4, intermediate_size: 128, vocab_size: 100 });
112
+ const names = new Map();
113
+ const add = (n, elems) => names.set(n, { name: n, elems, bytes: elems * 2, dtype: "BF16", shape: [elems] });
114
+ const h = Arch.headTensors(spec);
115
+ add(h.emb, 100 * 64); add(h.nrmF, 64);
116
+ for (let l = 0; l < 4; l++) {
117
+ const lt = Arch.layerTensors(spec, l);
118
+ add(lt.nrm1, 64); add(lt.nrm2, 64);
119
+ add(lt.Wq, 64 * 64); add(lt.Wk, 64 * 64); add(lt.Wv, 64 * 64); add(lt.Wo, 64 * 64);
120
+ add(lt.Wgate, 128 * 64); add(lt.Wup, 128 * 64); add(lt.Wdown, 64 * 128);
121
+ }
122
+ const Shard = require("./public/shard.js");
123
+ const mid = Shard.stageBytes(spec, names, { lo: 1, hi: 2, head: false }, Arch);
124
+ const head = Shard.stageBytes(spec, names, { lo: 0, hi: 1, head: true }, Arch);
125
+ assert.strictEqual(mid, (4 * 64 * 64 + 3 * 128 * 64 + 2 * 64) * 4, "middle stage cost is wrong");
126
+ assert.ok(head > mid, "the head must cost more — it holds the embedding table");
127
+ });
128
+
129
+ // ---- tokenizer --------------------------------------------------------------
130
+ // A tiny byte-level BPE built by hand, so encode/decode are checked against a
131
+ // known merge table rather than against themselves.
132
+ const TOKJSON = (() => {
133
+ const vocab = {};
134
+ let id = 0;
135
+ // single byte-level characters for the printable ASCII we use
136
+ for (const ch of "ĠabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.,!") vocab[ch] = id++;
137
+ vocab["ab"] = id++; vocab["abc"] = id++; vocab["Ġth"] = id++; vocab["Ġthe"] = id++;
138
+ const merges = ["a b", "ab c", "Ġ t h", "Ġth e"].map(m => m.replace(/^(\S+) (\S+)$/, "$1 $2"));
139
+ return { model: { type: "BPE", vocab, merges: ["a b", "ab c", "Ġt h", "Ġth e"] },
140
+ added_tokens: [{ id: id++, content: "<|end|>" }] };
141
+ })();
142
+
143
+ t("byte-level BPE round-trips text exactly", () => {
144
+ const tk = Tok.build(TOKJSON);
145
+ for (const s of ["abc", "abc abc", "the", " the", "hello world", "a,b!c."]) {
146
+ const ids = Tok.encode(tk, s);
147
+ assert.strictEqual(Tok.decode(tk, ids), s, `"${s}" did not round-trip (got "${Tok.decode(tk, ids)}")`);
148
+ }
149
+ });
150
+
151
+ t("BPE actually applies its merges", () => {
152
+ const tk = Tok.build(TOKJSON);
153
+ // "abc" must become the single merged token, not three characters
154
+ assert.deepStrictEqual([...Tok.encode(tk, "abc")], [tk.vocab["abc"]], "merges were not applied");
155
+ assert.deepStrictEqual([...Tok.encode(tk, "ab")], [tk.vocab["ab"]]);
156
+ });
157
+
158
+ t("added/special tokens stay whole and are hidden on decode", () => {
159
+ const tk = Tok.build(TOKJSON);
160
+ const ids = [...Tok.encode(tk, "abc<|end|>abc")];
161
+ assert.ok(ids.includes(tk.added.get("<|end|>")), "the special token was split up");
162
+ assert.strictEqual(Tok.decode(tk, ids), "abcabc", "special tokens should not appear in output text");
163
+ assert.ok(Tok.decode(tk, ids, true).includes("<|end|>"), "keepSpecial should show them");
164
+ });
165
+
166
+ t("a non-BPE tokenizer is refused rather than approximated", () => {
167
+ assert.throws(() => Tok.build({ model: { type: "Unigram", vocab: {} } }), /not supported/);
168
+ assert.throws(() => Tok.build({ model: { type: "WordPiece", vocab: {} } }), /not supported/);
169
+ });
170
+
171
+ t("the byte table covers all 256 bytes and is a bijection", () => {
172
+ const { b2u, u2b } = Tok.byteTables();
173
+ assert.strictEqual(b2u.size, 256, "byte->unicode table is incomplete");
174
+ assert.strictEqual(u2b.size, 256, "unicode->byte table is incomplete");
175
+ for (let b = 0; b < 256; b++) assert.strictEqual(u2b.get(b2u.get(b)), b, `byte ${b} does not round-trip`);
176
+ });
177
+
178
+ console.log(failed ? `\n${failed} failure(s)\n` : "\nall loader checks passed\n");
179
+ process.exit(failed ? 1 : 0);
test_pipeline.js ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The central claim of this project, as a test that runs.
2
+ //
3
+ // Splitting a model across machines is only safe if the split changes nothing.
4
+ // That is checkable here in a way it usually is not, because the pieces are
5
+ // deterministic: run a prompt with every layer on one "device", then again
6
+ // with the layers handed out across N simulated stages passing hidden states
7
+ // between them, and compare.
8
+ //
9
+ // The comparison is EXACT — bit patterns, not a tolerance. A correct split is
10
+ // not merely close to the unsplit result, it is identical, because the ring
11
+ // never re-derives anything; it only moves f32 arrays. A tolerance here would
12
+ // hide exactly the bugs worth finding: dropping one layer out of twelve moves
13
+ // the logits far less than you would guess, and still reads as fluent text.
14
+ //
15
+ // node test_pipeline.js
16
+ const assert = require("assert");
17
+ const Shard = require("./public/shard.js");
18
+ const Infer = require("./public/infer.js");
19
+ const Arch = require("./public/arch.js");
20
+
21
+ // The LUT is exactly a*b for int8 — what the verified mul8 unit is proven to
22
+ // reproduce. Rebuilding it here keeps the test fixture-free without weakening
23
+ // it, since the unit's whole claim is that it equals this.
24
+ const L = (() => {
25
+ const mul = new Int16Array(65536);
26
+ for (let a = 0; a < 256; a++)
27
+ for (let b = 0; b < 256; b++) mul[a * 256 + b] = (a > 127 ? a - 256 : a) * (b > 127 ? b - 256 : b);
28
+ return { mul };
29
+ })();
30
+ const ctx = () => ({ L, bgemm: null, att: null, mlp: null, audit: null }); // CPU mirror
31
+
32
+ function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
33
+ function rnd(n, seed, scale) {
34
+ const r = mulberry32(seed), o = new Float32Array(n);
35
+ for (let i = 0; i < n; i++) o[i] = (r() * 2 - 1) * (scale || 1);
36
+ return o;
37
+ }
38
+
39
+ // Build a weights map keyed exactly as the real repos key them, so the test
40
+ // exercises the same name resolution and layout handling the loader uses.
41
+ function makeWeights(spec) {
42
+ const got = new Map();
43
+ const C = spec.hidden, qDim = spec.heads * spec.headDim, kvDim = spec.kvHeads * spec.headDim;
44
+ let seed = 7;
45
+ const put = (n, len, scale) => got.set(n, rnd(len, seed++, scale ?? 0.08));
46
+ const h = Arch.headTensors(spec);
47
+ put(h.emb, spec.vocab * C, 0.05);
48
+ if (h.pos) put(h.pos, spec.maxPos * C, 0.02);
49
+ put(h.nrmF, C, 1);
50
+ got.set(h.nrmF, new Float32Array(C).fill(1));
51
+ if (spec.norm === "ln") got.set(h.nrmFb, new Float32Array(C));
52
+ if (!spec.tie) put(h.lmHead, spec.vocab * C, 0.05);
53
+ for (let l = 0; l < spec.layers; l++) {
54
+ const t = Arch.layerTensors(spec, l);
55
+ got.set(t.nrm1, new Float32Array(C).fill(1));
56
+ got.set(t.nrm2, new Float32Array(C).fill(1));
57
+ if (spec.norm === "ln") { got.set(t.nrm1b, new Float32Array(C)); got.set(t.nrm2b, new Float32Array(C)); }
58
+ if (spec.qkvFused) { put(t.Wqkv, C * 3 * C); got.set(t.bqkv, new Float32Array(3 * C)); }
59
+ else { put(t.Wq, qDim * C); put(t.Wk, kvDim * C); put(t.Wv, kvDim * C); }
60
+ put(t.Wo, C * qDim);
61
+ if (spec.qkvFused) got.set(t.bo, new Float32Array(C));
62
+ if (spec.gated) { put(t.Wgate, spec.inter * C); put(t.Wup, spec.inter * C); }
63
+ else { put(t.Wfc, spec.inter * C); got.set(t.bfc, new Float32Array(spec.inter)); }
64
+ put(t.Wdown, C * spec.inter);
65
+ if (!spec.gated) got.set(t.bdown, new Float32Array(C));
66
+ }
67
+ return got;
68
+ }
69
+
70
+ function stagesFor(spec, got, splits, T) {
71
+ const plan = [];
72
+ let lo = 0;
73
+ splits.forEach((n, i) => { plan.push({ id: "p" + i, index: i, lo, hi: lo + n, head: i === 0 }); lo += n; });
74
+ assert.strictEqual(lo, spec.layers, "splits must cover every layer exactly once");
75
+ return plan.map(st => Infer.makeStage(spec, st, Shard.stageWeights(spec, st, got, Arch), ctx(), T));
76
+ }
77
+
78
+ async function ringGenerate(spec, stages, ids, nTok, T) {
79
+ const head = stages[0];
80
+ const out = [...ids];
81
+ for (let n = 0; n < nTok; n++) {
82
+ const win = new Int32Array(T);
83
+ const tail = out.slice(-T);
84
+ for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
85
+ let x = Infer.embed(head, win);
86
+ for (const S of stages) x = await Infer.runLayers(S, x); // the "hop"
87
+ out.push(Infer.pickToken(await Infer.readout(head, x), { temperature: 0 }));
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function bitsEqual(a, b) {
93
+ if (a.length !== b.length) return false;
94
+ const ua = new Uint32Array(a.buffer, a.byteOffset, a.length);
95
+ const ub = new Uint32Array(b.buffer, b.byteOffset, b.length);
96
+ for (let i = 0; i < ua.length; i++) if (ua[i] !== ub[i]) return false;
97
+ return true;
98
+ }
99
+
100
+ // Two architectures, because the whole point of arch.js is that the ring does
101
+ // not care which one it is running. Llama-style exercises RMSNorm/RoPE/GQA/
102
+ // SwiGLU; GPT-2-style exercises LayerNorm+bias, fused QKV and GELU.
103
+ const LLAMA = Arch.fromConfig({
104
+ model_type: "llama", hidden_size: 64, num_hidden_layers: 6, num_attention_heads: 4,
105
+ num_key_value_heads: 2, intermediate_size: 128, vocab_size: 128,
106
+ max_position_embeddings: 64, rms_norm_eps: 1e-5, tie_word_embeddings: true,
107
+ });
108
+ const GPT2 = Arch.fromConfig({
109
+ model_type: "gpt2", n_embd: 64, n_layer: 6, n_head: 4, vocab_size: 128,
110
+ n_positions: 64, n_inner: 128, layer_norm_epsilon: 1e-5,
111
+ });
112
+
113
+ (async function () {
114
+ let failed = 0;
115
+ const t = (name, fn) => fn().then(
116
+ () => console.log(` ok ${name}`),
117
+ (e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
118
+
119
+ console.log("\nDaisyChain-Infer — pipeline equivalence\n");
120
+
121
+ const T = 16, PROMPT = [5, 11, 42, 7];
122
+
123
+ for (const [label, spec] of [["llama-style", LLAMA], ["gpt2-style", GPT2]]) {
124
+ console.log(` ${label}: ${spec.layers} layers, hidden ${spec.hidden}, ` +
125
+ `heads ${spec.heads}/${spec.kvHeads}, ${spec.gated ? "SwiGLU" : "GELU"}, ` +
126
+ `${spec.norm.toUpperCase()}, rope=${spec.rope}`);
127
+ const got = makeWeights(spec);
128
+ const ref = await ringGenerate(spec, stagesFor(spec, got, [spec.layers], T), PROMPT, 8, T);
129
+
130
+ await t(`${label}: a stage boundary changes no bit of the hidden state`, async () => {
131
+ const whole = stagesFor(spec, got, [spec.layers], T);
132
+ const split = stagesFor(spec, got, [2, 2, 2], T);
133
+ const win = new Int32Array(T);
134
+ for (let i = 0; i < PROMPT.length; i++) win[T - PROMPT.length + i] = PROMPT[i];
135
+ const a = await Infer.runLayers(whole[0], Infer.embed(whole[0], win));
136
+ let b = Infer.embed(split[0], win);
137
+ for (const S of split) b = await Infer.runLayers(S, b);
138
+ assert.ok(bitsEqual(a, b), "hidden states diverged across the split");
139
+ });
140
+
141
+ for (const splits of [[3, 3], [1, 2, 3], [0, 3, 3], [2, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]]) {
142
+ await t(`${label}: generation is identical when split ${JSON.stringify(splits)}`, async () => {
143
+ const out = await ringGenerate(spec, stagesFor(spec, got, splits, T), PROMPT, 8, T);
144
+ assert.deepStrictEqual(out, ref, `split ${splits} produced different tokens`);
145
+ });
146
+ }
147
+
148
+ // Mutation checks. Without these, a test where both sides call the same
149
+ // code proves nothing at all.
150
+ await t(`${label}: a stage that silently skips a layer IS caught`, async () => {
151
+ const stages = stagesFor(spec, got, [2, 2, 2], T);
152
+ stages[1].w.layers.pop();
153
+ const out = await ringGenerate(spec, stages, PROMPT, 8, T);
154
+ assert.notDeepStrictEqual(out, ref, "dropping a whole layer changed nothing — the check is blind");
155
+ });
156
+ await t(`${label}: stages applied out of order ARE caught`, async () => {
157
+ const stages = stagesFor(spec, got, [2, 2, 2], T);
158
+ const out = await ringGenerate(spec, [stages[0], stages[2], stages[1]], PROMPT, 8, T);
159
+ assert.notDeepStrictEqual(out, ref, "layer order did not matter — the check is blind");
160
+ });
161
+ }
162
+
163
+ // A wrong transpose does not throw; it yields a model that generates
164
+ // confident nonsense. So the layout conversion gets its own check.
165
+ await t("weight layout conversion is a real transpose, and in_out is a no-op", async () => {
166
+ const w = Float32Array.from([1, 2, 3, 4, 5, 6]); // (out=2, in=3)
167
+ const kn = Shard.toKN(w, 2, 3, "out_in");
168
+ assert.deepStrictEqual([...kn], [1, 4, 2, 5, 3, 6], "out_in did not transpose to k x n");
169
+ assert.strictEqual(Shard.toKN(w, 2, 3, "in_out"), w, "in_out must pass through untouched");
170
+ });
171
+
172
+ await t("architecture detection maps real configs, and refuses unknown ones", async () => {
173
+ assert.strictEqual(Arch.fromConfig({ model_type: "qwen2", hidden_size: 896, num_hidden_layers: 24,
174
+ num_attention_heads: 14, num_key_value_heads: 2, intermediate_size: 4864, vocab_size: 151936 }).family, "llama");
175
+ assert.strictEqual(Arch.fromConfig({ model_type: "gpt2", n_embd: 768, n_layer: 12,
176
+ n_head: 12, vocab_size: 50257 }).family, "gpt2");
177
+ assert.throws(() => Arch.fromConfig({ model_type: "mamba", hidden_size: 8, num_hidden_layers: 1,
178
+ num_attention_heads: 1, vocab_size: 8 }), /unsupported architecture/);
179
+ assert.throws(() => Arch.fromConfig({ model_type: "llama" }), /missing/);
180
+ });
181
+
182
+ await t("planning is deterministic and capacity-weighted", async () => {
183
+ const caps = [{ id: "p1", capacity: 100 }, { id: "p2", capacity: 300 }, { id: "p3", capacity: 200 }];
184
+ const a = Shard.planStages(LLAMA, caps), b = Shard.planStages(LLAMA, caps);
185
+ assert.deepStrictEqual(a, b, "planning is not deterministic");
186
+ assert.strictEqual(a.reduce((s, x) => s + (x.hi - x.lo), 0), LLAMA.layers, "plan does not cover every layer");
187
+ assert.ok(a[1].hi - a[1].lo > a[0].hi - a[0].lo, "the faster device did not get more layers");
188
+ assert.ok(a[0].head, "stage 0 must be the head");
189
+ });
190
+
191
+ console.log(failed ? `\n${failed} failure(s)\n` : "\nall pipeline checks passed\n");
192
+ process.exit(failed ? 1 : 0);
193
+ })();
test_wire.js ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Protocol round-trips, and the malformed messages the protocol must refuse.
2
+ //
3
+ // The reason this file exists separately from test_pipeline.js: the pipeline
4
+ // test proves the MATH survives being split. It cannot see a protocol bug,
5
+ // because it never encodes anything — it hands Float32Arrays between stages
6
+ // directly. Every real failure of a distributed system lives in the gap those
7
+ // two tests leave for each other, which is exactly what DaisyChain-Web's own
8
+ // self-corpus writeup concluded after a stalled roster gradient turned out to
9
+ // be a protocol bug that no data oracle could fire on.
10
+ //
11
+ // node test_wire.js
12
+ const assert = require("assert");
13
+ const Wire = require("./public/wire.js");
14
+ const Shard = require("./public/shard.js");
15
+
16
+ const SPEC = { family: "llama", layers: 6, hidden: 64, heads: 4, kvHeads: 2, vocab: 128 };
17
+ const PLAN = [{ id: "p1", index: 0, lo: 0, hi: 2, head: true },
18
+ { id: "p7", index: 1, lo: 2, hi: 4, head: false },
19
+ { id: "p3", index: 2, lo: 4, hi: 6, head: false }];
20
+ function assign(over) { return Object.assign({ repo: "owner/model", revision: "main", spec: SPEC, plan: PLAN, mine: 1, fingerprint: 123 }, over || {}); }
21
+
22
+ function bitsEqual(a, b) {
23
+ if (a.length !== b.length) return false;
24
+ const ua = new Uint32Array(a.buffer, a.byteOffset, a.length);
25
+ const ub = new Uint32Array(b.buffer, b.byteOffset, b.length);
26
+ for (let i = 0; i < ua.length; i++) if (ua[i] !== ub[i]) return false;
27
+ return true;
28
+ }
29
+
30
+ let failed = 0;
31
+ function t(name, fn) {
32
+ try { fn(); console.log(` ok ${name}`); }
33
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
34
+ }
35
+
36
+ console.log("\nDaisyChain-Infer — wire protocol\n");
37
+
38
+ t("hello round-trips, including the backend string", () => {
39
+ const b = Wire.packHello(1234.5, 0xDEADBEEF, "webgpu");
40
+ assert.strictEqual(Wire.tagOf(b), Wire.HELLO);
41
+ const h = Wire.unpackHello(b);
42
+ assert.strictEqual(Math.fround(1234.5), h.capacity);
43
+ assert.strictEqual(h.probeHash, 0xDEADBEEF);
44
+ assert.strictEqual(h.backend, "webgpu");
45
+ });
46
+
47
+ t("assignment round-trips the model identity and the plan", () => {
48
+ const a = Wire.unpackAssign(Wire.packAssign(assign()));
49
+ assert.strictEqual(a.repo, "owner/model");
50
+ assert.strictEqual(a.revision, "main");
51
+ assert.strictEqual(a.mine, 1);
52
+ assert.strictEqual(a.spec.layers, 6);
53
+ assert.deepStrictEqual(a.plan.map(s2 => [s2.lo, s2.hi]), [[0, 2], [2, 4], [4, 6]]);
54
+ });
55
+
56
+ // An assignment carries no weights by design — each device fetches its own
57
+ // layers from the Hub. This asserts the message stays small no matter how big
58
+ // the model is, which is what keeps a token off the wire and a peer from ever
59
+ // shipping weights to another peer.
60
+ t("an assignment carries no weight data", () => {
61
+ const buf = Wire.packAssign(assign({ spec: Object.assign({}, SPEC, { hidden: 4096, layers: 80, vocab: 128000 }) }));
62
+ assert.ok(buf.byteLength < 2048, `assignment is ${buf.byteLength} bytes — it must never carry weights`);
63
+ });
64
+
65
+ // The failure this rejects produces a plausible answer rather than an error:
66
+ // a plan missing a layer still generates fluent text.
67
+ t("a plan that does not cover every layer is refused", () => {
68
+ const gap = [{ id: "p1", lo: 0, hi: 2, head: true }, { id: "p2", lo: 3, hi: 6, head: false }];
69
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: gap }))), /cover 5 of 6/);
70
+ const over = [{ id: "p1", lo: 0, hi: 4, head: true }, { id: "p2", lo: 2, hi: 6, head: false }];
71
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: over }))), /cover 8 of 6/);
72
+ const oob = [{ id: "p1", lo: 0, hi: 9, head: true }];
73
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: oob }))), /out of bounds/);
74
+ });
75
+
76
+ t("an assignment whose stage 0 is not the head is refused", () => {
77
+ const bad = [{ id: "p1", lo: 0, hi: 3, head: false }, { id: "p2", lo: 3, hi: 6, head: true }];
78
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: bad }))), /stage 0 must be the head/);
79
+ });
80
+
81
+ // A repo id is interpolated straight into a Hub URL, so it is validated rather
82
+ // than trusted: a peer should not be able to point this device at an arbitrary
83
+ // path by sending a crafted assignment.
84
+ t("a malformed repo id is refused", () => {
85
+ for (const repo of ["", "nope", "../../etc/passwd", "owner/name/extra", "owner/na me"])
86
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ repo }))), /repo id/, `accepted "${repo}"`);
87
+ });
88
+
89
+ t("an assignment with no valid stage index for this device is refused", () => {
90
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ mine: 9 }))), /stage index/);
91
+ assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ mine: -1 }))), /stage index/);
92
+ });
93
+
94
+ t("ready round-trips", () => {
95
+ const r = Wire.unpackReady(Wire.packReady(2, true, "loaded"));
96
+ assert.strictEqual(r.stageIndex, 2);
97
+ assert.strictEqual(r.ok, true);
98
+ const bad = Wire.unpackReady(Wire.packReady(1, false, "out of memory"));
99
+ assert.strictEqual(bad.ok, false);
100
+ assert.strictEqual(bad.note, "out of memory");
101
+ });
102
+
103
+ t("activation round-trips and carries both hashes", () => {
104
+ const hidden = new Float32Array(16 * 32);
105
+ for (let i = 0; i < hidden.length; i++) hidden[i] = Math.cos(i) * 0.5;
106
+ const buf = Wire.packAct(7, 3, 2, hidden, Shard.hashF32, 0x5150);
107
+ const a = Wire.unpackAct(buf, Shard.hashF32);
108
+ assert.strictEqual(a.seq, 7);
109
+ assert.strictEqual(a.tokenIdx, 3);
110
+ assert.strictEqual(a.nextIndex, 2);
111
+ assert.strictEqual(a.modelHash, 0x5150);
112
+ assert.ok(bitsEqual(a.hidden, hidden), "hidden state changed on the wire");
113
+ });
114
+
115
+ // Corruption in an activation is the quietest failure in the whole system:
116
+ // the next stage happily consumes any float array of the right length.
117
+ t("a corrupted activation is caught by its hash", () => {
118
+ const hidden = new Float32Array(64).fill(1.5);
119
+ const buf = Wire.packAct(1, 0, 1, hidden, Shard.hashF32, 9);
120
+ new Float32Array(buf, 24)[10] = 1.5000001; // one ulp, deep in the payload
121
+ assert.throws(() => Wire.unpackAct(buf, Shard.hashF32), /integrity hash/);
122
+ });
123
+
124
+ t("-0 in an activation survives, and is distinguished from +0", () => {
125
+ // The parent project learned this the hard way: `!==` says -0 === 0, but the
126
+ // hash sees the bits. An activation carrying -0 must arrive as -0, and a
127
+ // flip to +0 must not slip past the integrity check.
128
+ const hidden = new Float32Array(8);
129
+ hidden[3] = -0;
130
+ const buf = Wire.packAct(1, 0, 1, hidden, Shard.hashF32, 9);
131
+ const a = Wire.unpackAct(buf, Shard.hashF32);
132
+ assert.ok(Object.is(a.hidden[3], -0), "-0 did not survive the wire");
133
+ new Float32Array(buf, 24)[3] = 0;
134
+ assert.throws(() => Wire.unpackAct(buf, Shard.hashF32), /integrity hash/);
135
+ });
136
+
137
+ t("token and done round-trip", () => {
138
+ const tk = Wire.unpackToken(Wire.packToken(4, 1337, 42));
139
+ assert.deepStrictEqual([tk.seq, tk.id, tk.total], [4, 1337, 42]);
140
+ assert.strictEqual(Wire.tagOf(Wire.packDone(9)), Wire.DONE);
141
+ });
142
+
143
+ // ---- routing ---------------------------------------------------------------
144
+ // These exist because of a real bug, and they are the shape of test that would
145
+ // have caught it. The head is both stage 0 and the ring's terminus, so the
146
+ // returning activation was addressed to index 0 and every stage read that as
147
+ // "stage 0, run your blocks" — the head re-ran its own layers and forwarded
148
+ // again, and the lap never closed. Every number involved was correct. Every
149
+ // message round-tripped. The pipeline test could not see it because it calls
150
+ // the stages in order itself, and the codec test could not see it because the
151
+ // bytes were fine. The defect was in the ROUTE, so the check has to walk one.
152
+ function walkLap(plan) {
153
+ // Simulate one lap: start at stage 0, follow routeAfter/classifyAct, and
154
+ // record who runs. Returns the sequence of stage indices that computed.
155
+ const ran = [];
156
+ let addr = 0, guard = 0;
157
+ for (;;) {
158
+ if (++guard > 100) throw new Error("lap did not terminate — the ring is cycling");
159
+ let actor = null;
160
+ for (const s of plan) if (Wire.classifyAct(addr, s.index) === "mine") {
161
+ if (actor !== null) throw new Error(`two stages both claim address ${addr}`);
162
+ actor = s.index;
163
+ }
164
+ if (actor === null) {
165
+ if (Wire.classifyAct(addr, 0) !== "return") throw new Error(`address ${addr} is claimed by nobody`);
166
+ return ran; // lap closed at the head
167
+ }
168
+ ran.push(actor);
169
+ addr = Wire.routeAfter(plan, actor).address;
170
+ }
171
+ }
172
+
173
+ t("a lap visits every stage exactly once and terminates at the head", () => {
174
+ for (const n of [1, 2, 3, 5, 8]) {
175
+ const plan = Array.from({ length: n }, (_, i) => ({ id: "p" + i, index: i, lo: i, hi: i + 1, head: i === 0 }));
176
+ assert.deepStrictEqual(walkLap(plan), plan.map(s => s.index),
177
+ `a ${n}-stage ring did not visit each stage exactly once in order`);
178
+ }
179
+ });
180
+
181
+ t("the return leg is addressed differently from stage 0", () => {
182
+ const plan = [{ id: "p0", index: 0 }, { id: "p1", index: 1 }];
183
+ const last = Wire.routeAfter(plan, 1);
184
+ assert.strictEqual(last.to, "p0", "the last stage must hand back to the head");
185
+ assert.ok(last.isReturn, "the last hop must be flagged as the return leg");
186
+ assert.notStrictEqual(last.address, 0,
187
+ "the return leg is addressed as stage 0 — the head will re-run its own blocks and the lap will never close");
188
+ assert.strictEqual(Wire.classifyAct(last.address, 0), "return");
189
+ assert.strictEqual(Wire.classifyAct(0, 0), "mine"); // outbound, same number, different meaning
190
+ });
191
+
192
+ t("a single-stage ring returns to itself immediately", () => {
193
+ const solo = [{ id: "p0", index: 0 }];
194
+ const hop = Wire.routeAfter(solo, 0);
195
+ assert.ok(hop.isReturn && hop.to === "p0");
196
+ assert.deepStrictEqual(walkLap(solo), [0]);
197
+ });
198
+
199
+ t("every sentinel is distinct and cannot collide with a step number", () => {
200
+ const tags = [Wire.FRAG, Wire.HELLO, Wire.ASSIGN, Wire.READY, Wire.ACT, Wire.TOKEN, Wire.DONE];
201
+ assert.strictEqual(new Set(tags).size, tags.length, "two sentinels share a value");
202
+ for (const t2 of tags) assert.ok(t2 < 0, `sentinel ${t2} is not negative — it could be mistaken for data`);
203
+ // DaisyChain-Web uses -2..-8; ours start at -20 so a misdirected client sees
204
+ // an unknown tag rather than a valid message of the wrong kind.
205
+ for (const t2 of tags) assert.ok(t2 === Wire.FRAG || t2 <= -20, `sentinel ${t2} overlaps DaisyChain-Web's range`);
206
+ });
207
+
208
+ console.log(failed ? `\n${failed} failure(s)\n` : "\nall wire checks passed\n");
209
+ process.exit(failed ? 1 : 0);