Quazim0t0 commited on
Commit
9524b89
·
verified ·
1 Parent(s): aa965fc

DaisyChain-Web demo: P2P browser training through verified INT8 units

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules/
2
+ *.log
3
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+ WORKDIR /app
3
+ COPY package.json package-lock.json ./
4
+ RUN npm install --omit=dev
5
+ COPY . .
6
+ ENV PORT=7860
7
+ EXPOSE 7860
8
+ CMD ["node", "server.js"]
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 CHANGED
@@ -1,10 +1,31 @@
1
  ---
2
- title: DaisyChain Web
3
- emoji: 💻
4
- colorFrom: blue
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DaisyChain-Web
3
+ emoji: 🌼
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
+ short_description: Train a shared model P2P by opening a browser tab
11
  ---
12
 
13
+ # 🌼 DaisyChain-Web train by opening a page
14
+
15
+ Open this Space on two or more devices and they train a shared model together —
16
+ **peer-to-peer over WebRTC**, right in the browser, computing through verified
17
+ INT8 neural units (WebGPU, or the same units on CPU for old machines).
18
+
19
+ - Devices on the same network auto-group (Snapdrop-style).
20
+ - Cross-network: everyone opens `?room=YOUR-CODE` — the room's creator approves
21
+ each device before it can join.
22
+ - Gradients are averaged every step with a deterministic Adam optimizer, so all
23
+ devices end with bit-identical weights.
24
+ - Download your trained model, or upload a checkpoint to restore the whole group.
25
+
26
+ Part of **[DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train)** —
27
+ a pipeline for reusing old/spare hardware to train neural networks.
28
+
29
+ **Heads up:** peers connect directly (WebRTC), so devices in your group can see
30
+ each other's IP address, and there is no gradient authentication — only train
31
+ with devices/people you trust. Proof of concept.
package-lock.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "daisychain-web",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "daisychain-web",
9
+ "version": "0.1.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "ws": "^8.21.0"
13
+ }
14
+ },
15
+ "node_modules/ws": {
16
+ "version": "8.21.0",
17
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
18
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
19
+ "license": "MIT",
20
+ "engines": {
21
+ "node": ">=10.0.0"
22
+ },
23
+ "peerDependencies": {
24
+ "bufferutil": "^4.0.1",
25
+ "utf-8-validate": ">=5.0.2"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "bufferutil": {
29
+ "optional": true
30
+ },
31
+ "utf-8-validate": {
32
+ "optional": true
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
package.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "daisychain-web",
3
+ "version": "0.1.0",
4
+ "description": "Train a shared model P2P in the browser — WebRTC mesh + WebGPU compute. Open a page, become a node.",
5
+ "license": "MIT",
6
+ "author": "Dean Byrne (Quazim0t0) / DaisyChainAI",
7
+ "scripts": {
8
+ "start": "node server.js",
9
+ "test": "node test_core.js"
10
+ },
11
+ "dependencies": {
12
+ "ws": "^8.21.0"
13
+ }
14
+ }
public/app.js ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DaisyChain-Web client: connect P2P (WebRTC), compute (WebGPU or CPU), and
2
+ // train a shared model together — averaging gradients over the data channels.
3
+ "use strict";
4
+
5
+ const DIN = 16, H = 16, DOUT = 4, NPER = 128, LR = 0.03, DEFAULT_STEPS = 300;
6
+ const D = { n: NPER, din: DIN, h: H, dout: DOUT };
7
+ const STUN = [{ urls: "stun:stun.l.google.com:19302" }];
8
+
9
+ const ui = {
10
+ status: document.getElementById("status"),
11
+ backend: document.getElementById("backend"),
12
+ me: document.getElementById("me"),
13
+ peers: document.getElementById("peers"),
14
+ loss: document.getElementById("loss"),
15
+ step: document.getElementById("step"),
16
+ bar: document.getElementById("bar"),
17
+ diff: document.getElementById("diff"),
18
+ log: document.getElementById("log"),
19
+ start: document.getElementById("start"),
20
+ save: document.getElementById("save"),
21
+ load: document.getElementById("load"),
22
+ loadBtn: document.getElementById("loadBtn"),
23
+ requests: document.getElementById("requests"),
24
+ };
25
+ function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${m}\n` + ui.log.textContent; }
26
+ function setStatus(s) { ui.status.textContent = s; }
27
+
28
+ // ---- deterministic RNG so every peer agrees on W_true and W0 (no broadcast) --
29
+ 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; }; }
30
+ function randn(n, rng) { const r = rng || Math.random; const o = new Float32Array(n); for (let i = 0; i < n; i += 2) { let u = 0, v = 0; while (u === 0) u = r(); while (v === 0) v = r(); const m = Math.sqrt(-2 * Math.log(u)); o[i] = m * Math.cos(2 * Math.PI * v); if (i + 1 < n) o[i + 1] = m * Math.sin(2 * Math.PI * v); } return o; }
31
+
32
+ // ---- state -----------------------------------------------------------------
33
+ // friendly device name (cottagecore, Snapdrop-style)
34
+ const ADJ = ["Mossy", "Golden", "Amber", "Fern", "Hazel", "Cozy", "Wandering", "Little",
35
+ "Sunny", "Misty", "Wild", "Quiet", "Brave", "Dusty", "Merry"];
36
+ const NOUN = ["Fox", "Hare", "Owl", "Badger", "Toad", "Sparrow", "Otter", "Deer",
37
+ "Hedgehog", "Mushroom", "Acorn", "Willow", "Robin", "Fawn", "Moth"];
38
+ const deviceName = ADJ[Math.floor(Math.random() * ADJ.length)] + " " +
39
+ NOUN[Math.floor(Math.random() * NOUN.length)];
40
+
41
+ let myId = null, compute = null, ws = null, L = null, wasDenied = false;
42
+ const pcs = new Map(), chans = new Map(); // peerId -> RTCPeerConnection / DataChannel
43
+ const names = new Map(); // peerId -> device name
44
+ const incoming = new Map(); // step -> Map(peerId -> Float32Array)
45
+ let W1, W2, Xdata, Ydata, training = false; // 2-layer verified model
46
+ let trainedSteps = 0; // steps baked into the current weights
47
+ function nmeOf(id) { return names.get(id) || id; }
48
+
49
+ function room() { return new URLSearchParams(location.search).get("room"); } // null -> group by network
50
+ function updatePeers() { ui.peers.textContent = chans.size ? [...chans.keys()].map(nmeOf).join(", ") : "(none yet)"; }
51
+
52
+ // ---- signaling + WebRTC ----------------------------------------------------
53
+ function connectSignaling() {
54
+ const proto = location.protocol === "https:" ? "wss" : "ws";
55
+ const params = new URLSearchParams();
56
+ params.set("name", deviceName);
57
+ if (room()) params.set("room", room()); // no code -> group by network
58
+ ws = new WebSocket(`${proto}://${location.host}/?${params}`);
59
+ ws.onopen = () => setStatus(room() ? `connected — private room "${room()}"` : "connected — grouping with devices on your network");
60
+ ws.onclose = () => { if (!wasDenied) setStatus("signaling disconnected"); };
61
+ ws.onmessage = async (ev) => {
62
+ const msg = JSON.parse(ev.data);
63
+ if (msg.type === "welcome") {
64
+ myId = msg.id;
65
+ if (msg.room) log(`group: ${msg.room.startsWith("net:") ? "your network" : "private room " + msg.room.replace("room:", "")}`);
66
+ if (msg.host) { log("you host this room — joiners wait for your approval"); setStatus(`hosting private room "${room()}"`); }
67
+ else if (room()) setStatus(`accepted into private room "${room()}"`);
68
+ // I'm newest: initiate to everyone already here
69
+ for (const p of msg.peers) { names.set(p.id, p.name); initiatePeer(p.id); }
70
+ } else if (msg.type === "waiting") {
71
+ setStatus("knocking — waiting for the room's host to let you in…");
72
+ } else if (msg.type === "denied") {
73
+ wasDenied = true;
74
+ setStatus("the host declined your request to join");
75
+ log("join request declined by the host");
76
+ } else if (msg.type === "host") {
77
+ log("the host left — you are now the host of this room");
78
+ setStatus(`hosting private room "${room()}"`);
79
+ } else if (msg.type === "join-request") {
80
+ addJoinRequest(msg.id, msg.name);
81
+ } else if (msg.type === "peer-joined") {
82
+ names.set(msg.id, msg.name);
83
+ log(`${msg.name} joined (they will connect to me)`);
84
+ } else if (msg.type === "peer-left") {
85
+ log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); updatePeers();
86
+ } else if (msg.type === "signal") {
87
+ await onSignal(msg.from, msg.data);
88
+ }
89
+ };
90
+ }
91
+ function signal(to, data) { ws.send(JSON.stringify({ type: "signal", to, data })); }
92
+
93
+ // host-side Accept/Deny row for a knocking device (textContent only — the name
94
+ // comes off the wire and must never be parsed as HTML)
95
+ function addJoinRequest(id, name) {
96
+ const row = document.createElement("div");
97
+ row.style.cssText = "display:flex;gap:8px;align-items:center;justify-content:space-between;margin-top:10px;flex-wrap:wrap";
98
+ const who = document.createElement("span");
99
+ who.textContent = `🚪 ${name} wants to join`;
100
+ const btn = (label, allow) => {
101
+ const b = document.createElement("button");
102
+ b.textContent = label;
103
+ b.style.cssText = "padding:6px 14px;font-size:.85rem" + (allow ? "" : ";background:#8b2e25");
104
+ b.onclick = () => {
105
+ ws.send(JSON.stringify({ type: "admit", id, allow }));
106
+ row.remove();
107
+ log(allow ? `you let ${name} in` : `you declined ${name}`);
108
+ };
109
+ return b;
110
+ };
111
+ row.append(who, btn("Accept", true), btn("Deny", false));
112
+ ui.requests.appendChild(row);
113
+ }
114
+
115
+ function newPC(peerId) {
116
+ const pc = new RTCPeerConnection({ iceServers: STUN });
117
+ pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
118
+ pc.onconnectionstatechange = () => { if (pc.connectionState === "failed") cleanupPeer(peerId); };
119
+ pcs.set(peerId, pc);
120
+ return pc;
121
+ }
122
+ function initiatePeer(peerId) {
123
+ const pc = newPC(peerId);
124
+ const dc = pc.createDataChannel("daisy");
125
+ setupChannel(peerId, dc);
126
+ pc.createOffer().then(o => pc.setLocalDescription(o)).then(() => signal(peerId, { sdp: pc.localDescription }));
127
+ }
128
+ async function onSignal(from, data) {
129
+ let pc = pcs.get(from);
130
+ if (data.sdp) {
131
+ if (!pc) { pc = newPC(from); pc.ondatachannel = (e) => setupChannel(from, e.channel); }
132
+ await pc.setRemoteDescription(data.sdp);
133
+ if (data.sdp.type === "offer") {
134
+ const ans = await pc.createAnswer(); await pc.setLocalDescription(ans);
135
+ signal(from, { sdp: pc.localDescription });
136
+ }
137
+ } else if (data.candidate && pc) {
138
+ try { await pc.addIceCandidate(data.candidate); } catch (e) {}
139
+ }
140
+ }
141
+ function setupChannel(peerId, dc) {
142
+ dc.binaryType = "arraybuffer";
143
+ dc.onopen = () => { chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); ui.start.disabled = false; };
144
+ dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); };
145
+ dc.onmessage = (e) => onGrad(peerId, e.data);
146
+ }
147
+ function cleanupPeer(id) { const pc = pcs.get(id); if (pc) pc.close(); pcs.delete(id); chans.delete(id); wake(); }
148
+
149
+ // ---- checkpoints ------------------------------------------------------------
150
+ // File layout (also the broadcast payload after the sentinel):
151
+ // 8 bytes magic "DAISYPT1" | int32 din,h,dout,steps | f32 W1 | f32 W2
152
+ // DaisyChain's own format (not torch-pickle) — .pt extension for familiarity.
153
+ const CKPT_MAGIC = "DAISYPT1";
154
+ const CKPT_SENTINEL = -2; // wire: [int32 -2][checkpoint bytes]
155
+
156
+ function packCheckpoint() {
157
+ const buf = new ArrayBuffer(8 + 16 + (W1.length + W2.length) * 4);
158
+ new Uint8Array(buf, 0, 8).set([...CKPT_MAGIC].map(c => c.charCodeAt(0)));
159
+ new Int32Array(buf, 8, 4).set([DIN, H, DOUT, trainedSteps]);
160
+ new Float32Array(buf, 24, W1.length).set(W1);
161
+ new Float32Array(buf, 24 + W1.length * 4, W2.length).set(W2);
162
+ return buf;
163
+ }
164
+ function parseCheckpoint(buf) {
165
+ const magic = String.fromCharCode(...new Uint8Array(buf, 0, 8));
166
+ if (magic !== CKPT_MAGIC) throw new Error("not a DaisyChain checkpoint");
167
+ const [din, h, dout, steps] = new Int32Array(buf, 8, 4);
168
+ if (din !== DIN || h !== H || dout !== DOUT)
169
+ throw new Error(`shape mismatch: file is ${din}×${h}×${dout}, this build is ${DIN}×${H}×${DOUT}`);
170
+ if (buf.byteLength !== 24 + (din * h + h * dout) * 4) throw new Error("truncated checkpoint");
171
+ return { steps, w1: new Float32Array(buf.slice(24, 24 + din * h * 4)),
172
+ w2: new Float32Array(buf.slice(24 + din * h * 4)) };
173
+ }
174
+ function applyCheckpoint(ck, from) {
175
+ W1.set(ck.w1); W2.set(ck.w2); trainedSteps = ck.steps;
176
+ ui.save.disabled = false;
177
+ ui.step.textContent = `${ck.steps} baked in`;
178
+ log(`checkpoint loaded (${ck.steps} steps) ${from ? "from " + from : "from file"} — all set to resume`);
179
+ }
180
+ function broadcastCheckpoint() {
181
+ const ck = packCheckpoint();
182
+ const msg = new ArrayBuffer(4 + ck.byteLength);
183
+ new Int32Array(msg, 0, 1)[0] = CKPT_SENTINEL;
184
+ new Uint8Array(msg, 4).set(new Uint8Array(ck));
185
+ let n = 0;
186
+ for (const dc of chans.values()) if (dc.readyState === "open") { dc.send(msg); n++; }
187
+ log(`checkpoint pushed to ${n} device(s)`);
188
+ }
189
+ function saveCheckpoint() {
190
+ const blob = new Blob([packCheckpoint()], { type: "application/octet-stream" });
191
+ const a = document.createElement("a");
192
+ a.href = URL.createObjectURL(blob);
193
+ a.download = `daisychain-${DIN}x${H}x${DOUT}-step${trainedSteps}.pt`;
194
+ a.click();
195
+ URL.revokeObjectURL(a.href);
196
+ }
197
+
198
+ // ---- gradient wire format: [int32 step][float32 grad...] -----------------
199
+ function packGrad(step, grad) {
200
+ const buf = new ArrayBuffer(4 + grad.byteLength);
201
+ new Int32Array(buf, 0, 1)[0] = step;
202
+ new Float32Array(buf, 4).set(grad);
203
+ return buf;
204
+ }
205
+ const waiters = new Set(); // pending waitForGrads checkers
206
+ function wake() { for (const w of waiters) w(); }
207
+ function onGrad(peerId, buf) {
208
+ const step = new Int32Array(buf, 0, 1)[0];
209
+ if (step === CKPT_SENTINEL) { // a peer pushed a checkpoint
210
+ if (training) { log(`ignored checkpoint from ${nmeOf(peerId)} (training in progress)`); return; }
211
+ try { applyCheckpoint(parseCheckpoint(buf.slice(4)), nmeOf(peerId)); }
212
+ catch (e) { log(`bad checkpoint from ${nmeOf(peerId)}: ${e.message}`); }
213
+ return;
214
+ }
215
+ const grad = new Float32Array(buf.slice(4));
216
+ if (!incoming.has(step)) incoming.set(step, new Map());
217
+ incoming.get(step).set(peerId, grad);
218
+ wake(); // resolve waits immediately (no polling)
219
+ }
220
+ function broadcastGrad(step, grad) { const b = packGrad(step, grad); for (const dc of chans.values()) if (dc.readyState === "open") dc.send(b); }
221
+ // Event-driven: re-checked on every gradient arrival and peer departure, plus a
222
+ // coarse fallback timer (background tabs throttle timers to ~1s, so the old
223
+ // 15ms poll was the bottleneck there). Peers that left are dropped from the
224
+ // wait — a device dying no longer costs the full timeout every step.
225
+ function waitForGrads(step, cohort, timeoutMs = 8000) {
226
+ return new Promise((resolve) => {
227
+ const t0 = Date.now();
228
+ let timer = null;
229
+ const check = () => {
230
+ const live = cohort.filter(id => chans.has(id)); // prune departed peers
231
+ const got = incoming.get(step) || new Map();
232
+ const have = live.filter(id => got.has(id));
233
+ if (have.length === live.length || Date.now() - t0 > timeoutMs) {
234
+ waiters.delete(check); clearInterval(timer);
235
+ resolve(have.map(id => got.get(id)));
236
+ }
237
+ };
238
+ waiters.add(check);
239
+ timer = setInterval(check, 500); // safety net only
240
+ check();
241
+ });
242
+ }
243
+
244
+ // ---- compute: one async training step THROUGH the verified units -----------
245
+ async function localStep() {
246
+ // forward runs through the verified INT8 multiply (WebGPU or CPU); STE backward
247
+ const fwd = await Verified.forward(Xdata, Ydata, W1, W2, D, L, compute.matmulInt8);
248
+ const grad = Verified.backward(Xdata, W1, W2, fwd, D); // flat [gW1, gW2]
249
+ return { loss: fwd.loss, grad };
250
+ }
251
+
252
+ // ---- the training loop -----------------------------------------------------
253
+ async function train() {
254
+ if (training) return; training = true; ui.start.disabled = true;
255
+ const cohort = [...chans.keys()]; // lock the cohort (departed peers are pruned per-step)
256
+ const steps = DEFAULT_STEPS;
257
+ const opt = TrainCore.makeAdam(W1.length + W2.length, { lr: 0.2 }); // swept: best on the verified-unit STE grads
258
+ log(`training started — cohort ${cohort.length} peer(s), world ${cohort.length + 1}, optimizer ${opt.name}`);
259
+ for (let s = 0; s < steps; s++) {
260
+ const { loss, grad } = await localStep();
261
+ broadcastGrad(s, grad);
262
+ const remote = cohort.length ? await waitForGrads(s, cohort) : [];
263
+ const all = [grad, ...remote];
264
+ const avg = TrainCore.averageGrads(all);
265
+ const upd = opt.step(avg); // DaisyAdam on the cluster-avg grad
266
+ Verified.splitApply(W1, W2, upd, 1); // W -= 1 * upd (lr folded into upd)
267
+ incoming.delete(s);
268
+ trainedSteps++;
269
+ if (s % 10 === 0 || s === steps - 1) {
270
+ ui.loss.textContent = loss.toFixed(5);
271
+ ui.step.textContent = `${s + 1} / ${steps}`;
272
+ ui.bar.style.width = `${Math.round(100 * (s + 1) / steps)}%`;
273
+ await new Promise(r => setTimeout(r, 0)); // yield to UI
274
+ }
275
+ }
276
+ ui.diff.textContent = `done — trained through the verified units; all peers share one model.`;
277
+ log(`training done — final loss ${ui.loss.textContent}`);
278
+ training = false;
279
+ ui.save.disabled = false;
280
+ ui.start.disabled = false;
281
+ }
282
+
283
+ // 2-layer float target (matches the model shape) for a learnable task
284
+ function target(X) {
285
+ const Wt1 = randn(DIN * H, mulberry32(42)), Wt2 = randn(H * DOUT, mulberry32(43));
286
+ const hpre = TrainCore.matmul(X, Wt1, NPER, DIN, H);
287
+ for (let i = 0; i < hpre.length; i++) hpre[i] = Math.max(0, hpre[i]);
288
+ return TrainCore.matmul(hpre, Wt2, NPER, H, DOUT);
289
+ }
290
+
291
+ // ---- boot ------------------------------------------------------------------
292
+ (async function () {
293
+ // Neural Units are mandatory: no LUTs -> no training, period. There is no
294
+ // float fallback path anywhere in this app; both backends (WebGPU shader and
295
+ // CPU JS) compute every product through the verified mul8 LUT.
296
+ try {
297
+ L = await Compute.loadLUTs(); // the verified units, as tables
298
+ if (!(L.mul instanceof Int16Array) || L.mul.length !== 65536)
299
+ throw new Error("mul8 LUT malformed");
300
+ // self-test: the unit must reproduce a known product before we trust it
301
+ if (L.mul[((7 & 0xFF) * 256) + (-3 & 0xFF)] !== -21)
302
+ throw new Error("mul8 LUT self-test failed (7 × -3 ≠ -21)");
303
+ compute = await Compute.initCompute(L);
304
+ } catch (e) {
305
+ setStatus("NEURAL UNITS UNAVAILABLE — training disabled");
306
+ ui.backend.textContent = "unavailable";
307
+ log(`FATAL: verified neural units failed to load (${e.message}). ` +
308
+ `This build only trains through the units — there is no fallback.`);
309
+ ui.start.disabled = true;
310
+ return; // no signaling, no training
311
+ }
312
+ ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label} · through verified INT8 units`;
313
+ // deterministic shared init (no weight broadcast); per-peer random data shard
314
+ W1 = randn(DIN * H, mulberry32(7));
315
+ W2 = randn(H * DOUT, mulberry32(8));
316
+ Xdata = randn(NPER * DIN);
317
+ Ydata = target(Xdata);
318
+ ui.me.textContent = deviceName;
319
+ updatePeers();
320
+ connectSignaling();
321
+ ui.start.onclick = train;
322
+ ui.save.onclick = saveCheckpoint;
323
+ ui.loadBtn.onclick = () => { if (training) { log("can't load a checkpoint mid-training"); return; } ui.load.click(); };
324
+ ui.load.onchange = async () => {
325
+ const f = ui.load.files[0]; ui.load.value = "";
326
+ if (!f) return;
327
+ try {
328
+ const ck = parseCheckpoint(await f.arrayBuffer());
329
+ applyCheckpoint(ck, null);
330
+ broadcastCheckpoint(); // every device resumes from this
331
+ } catch (e) { log(`checkpoint rejected: ${e.message}`); }
332
+ };
333
+ log(`ready — this device is "${deviceName}", computing through verified units on ${compute.backend.toUpperCase()}`);
334
+ })();
public/index.html ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-Web — train by opening a page</title>
7
+ <style>
8
+ :root {
9
+ --page-bg: #efe4c9;
10
+ --card-bg: #fbf6e8;
11
+ --card-border: rgba(139, 111, 71, 0.30);
12
+ --text: #2a1d0a;
13
+ --text-soft: #6b4423;
14
+ --accent: #4a7c2e;
15
+ --accent-deep: #2d5016;
16
+ --counter-bg: linear-gradient(135deg, #2d5016 0%, #1f3a0f 100%);
17
+ --counter-num: #f5ecd9;
18
+ --counter-label: #c9b072;
19
+ --btn: linear-gradient(135deg, #4a7c2e 0%, #2d5016 100%);
20
+ --btn-hover: linear-gradient(135deg, #5a8c3e 0%, #3d6020 100%);
21
+ --warn: #8b2e25;
22
+ --link: #4a7c2e;
23
+ --track: rgba(139, 111, 71, 0.22);
24
+ }
25
+ @media (prefers-color-scheme: dark) {
26
+ :root {
27
+ --page-bg: #14100a; --card-bg: #1f1a12; --card-border: rgba(201, 176, 114, 0.35);
28
+ --text: #ede1c3; --text-soft: #c9b072; --accent: #9bc466; --accent-deep: #6b9039;
29
+ --counter-bg: linear-gradient(135deg, #1a2e0d 0%, #0c1606 100%);
30
+ --counter-label: #c9b072; --warn: #ff9b8e; --link: #9bc466; --track: rgba(201,176,114,0.20);
31
+ }
32
+ }
33
+ * { box-sizing: border-box; }
34
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
35
+ max-width: 720px; margin: 0 auto; padding: 24px 18px 40px; line-height: 1.5;
36
+ background: var(--page-bg); color: var(--text); }
37
+ h1 { margin: 0 0 2px; font-size: 1.7rem; letter-spacing: .3px; }
38
+ .sub { color: var(--text-soft); margin: 0 0 18px; font-size: .92rem; }
39
+ .sub code { background: rgba(74,124,46,.12); padding: 1px 5px; border-radius: 4px; }
40
+ .card { background: var(--card-bg); border: 1px solid var(--card-border);
41
+ border-radius: 10px; padding: 14px 16px; margin: 12px 0;
42
+ box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
43
+ .lbl { color: var(--text-soft); font-size: 11px; font-weight: 800; letter-spacing: 1.5px;
44
+ text-transform: uppercase; margin-bottom: 8px; }
45
+ .row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; font-size: .95rem; }
46
+ .k { color: var(--text-soft); } .v { font-weight: 700; text-align: right; }
47
+ .device { font-family: 'Courier New', monospace; font-size: 1.5rem; font-weight: 700;
48
+ color: var(--accent-deep); }
49
+ @media (prefers-color-scheme: dark) { .device { color: var(--accent); } }
50
+ .counter { background: var(--counter-bg); border-radius: 10px; padding: 14px 16px; text-align: center; }
51
+ .counter .num { font-family: 'Courier New', monospace; font-size: 2.2rem; font-weight: 700; color: var(--counter-num); }
52
+ .counter .cl { color: var(--counter-label); font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; }
53
+ .track { width: 100%; height: 10px; border-radius: 6px; background: var(--track); overflow: hidden; margin-top: 12px; }
54
+ #bar { height: 10px; width: 0; background: linear-gradient(90deg, #6b9039, #9bc466); transition: width .25s; }
55
+ button { background: var(--btn); color: #f5ecd9; border: 0; border-radius: 8px;
56
+ padding: 12px 26px; font-weight: 800; font-size: 1rem; letter-spacing: .5px; cursor: pointer;
57
+ box-shadow: 0 3px 10px rgba(74,49,16,0.18); transition: .15s; }
58
+ button:hover:not(:disabled) { background: var(--btn-hover); box-shadow: 0 5px 14px rgba(74,49,16,0.28); }
59
+ button:disabled { background: #8b7d5e; opacity: .55; cursor: not-allowed; box-shadow: none; }
60
+ pre { background: rgba(0,0,0,0.05); border-radius: 8px; padding: 10px; max-height: 150px;
61
+ overflow: auto; font-size: .78rem; color: var(--text-soft); white-space: pre-wrap; margin: 0;
62
+ font-family: 'Courier New', monospace; }
63
+ @media (prefers-color-scheme: dark) { pre { background: rgba(0,0,0,0.25); } }
64
+ .note { color: var(--text-soft); font-size: .82rem; }
65
+ .note b { color: var(--warn); }
66
+ .diff { color: var(--accent-deep); font-weight: 600; font-size: .88rem; }
67
+ @media (prefers-color-scheme: dark) { .diff { color: var(--accent); } }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <h1>🌼 DaisyChain-Web</h1>
72
+ <p class="sub">Open this on your other devices <b>on the same network</b> and they train a shared model together — peer-to-peer, right in the browser, through the emulated GPU logic. Only devices on your network are grouped (like Snapdrop). To invite people across networks, everyone opens <code>?room=YOUR-CODE</code> — the person who created the room approves each device before it can join.</p>
73
+
74
+ <div class="card">
75
+ <div class="lbl">🌲 This device</div>
76
+ <div class="device" id="me">—</div>
77
+ <div class="row" style="margin-top:8px"><span class="k">Status</span><span class="v" id="status">starting…</span></div>
78
+ <div class="row"><span class="k">Compute</span><span class="v" id="backend">detecting…</span></div>
79
+ </div>
80
+
81
+ <div class="card">
82
+ <div class="lbl">🍄 Devices in your group</div>
83
+ <div class="row"><span class="v" id="peers" style="text-align:left">(none yet)</span></div>
84
+ <div id="requests"></div>
85
+ </div>
86
+
87
+ <div class="card" style="text-align:center">
88
+ <button id="start" disabled>Start training</button>
89
+ <p class="note" style="margin:.6rem 0 0">Enabled once another device joins. (Or open a second tab to try it.)</p>
90
+ </div>
91
+
92
+ <div class="card">
93
+ <div class="lbl">✦ Training</div>
94
+ <div class="row"><span class="k">Step</span><span class="v" id="step">— / —</span></div>
95
+ <div class="counter" style="margin:10px 0"><div class="num" id="loss">—</div><div class="cl">cluster-avg loss · lower is better</div></div>
96
+ <div class="track"><div id="bar"></div></div>
97
+ <div class="row" style="margin-top:10px"><span class="diff" id="diff"></span></div>
98
+ </div>
99
+
100
+ <div class="card">
101
+ <div class="lbl">💾 Model checkpoint</div>
102
+ <div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">
103
+ <button id="save" disabled>Download model (.pt)</button>
104
+ <button id="loadBtn">Load checkpoint…</button>
105
+ <input type="file" id="load" accept=".pt" style="display:none">
106
+ </div>
107
+ <p class="note" style="margin:.6rem 0 0;text-align:center">Loading a checkpoint applies it here <b>and</b> pushes it to every connected device — use it to recover the group after a failure.</p>
108
+ </div>
109
+
110
+ <div class="card">
111
+ <div class="lbl">❋ Log</div>
112
+ <pre id="log"></pre>
113
+ </div>
114
+
115
+ <p class="note">Needs a secure context (localhost or HTTPS) for WebGPU + cross-device WebRTC. No WebGPU? The same verified INT8 units run on CPU — old machines (e.g. via Supermium) still join, just slower. Every training step goes through the Neural Units; there is no plain-float path, and if the units fail to load, training is disabled.</p>
116
+ <p class="note"><b>Heads up:</b> peers connect directly (WebRTC), so devices in your group can see each other's IP address, and there's no gradient authentication — only train with devices/people you trust. Proof of concept.</p>
117
+
118
+ <script src="traincore.js"></script>
119
+ <script src="verified_core.js"></script>
120
+ <script src="webgpu.js"></script>
121
+ <script src="app.js"></script>
122
+ </body>
123
+ </html>
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/traincore.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ function averageGrads(grads) {
55
+ const out = new Float32Array(grads[0].length);
56
+ for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
57
+ for (let i = 0; i < out.length; i++) out[i] /= grads.length;
58
+ return out;
59
+ }
60
+
61
+ // DaisyAdam — Adam with bias correction, applied to the cluster-averaged
62
+ // gradient. State is a pure function of the gradient sequence, so every peer
63
+ // that averages the same gradients keeps bit-identical moments: no optimizer
64
+ // state ever crosses the wire. Momentum also smooths the noisy STE gradients
65
+ // coming out of the verified INT8 units.
66
+ function makeAdam(dim, opts) {
67
+ const o = opts || {};
68
+ const lr = o.lr ?? 0.02, b1 = o.beta1 ?? 0.9, b2 = o.beta2 ?? 0.999, eps = o.eps ?? 1e-8;
69
+ const m = new Float32Array(dim), v = new Float32Array(dim);
70
+ let t = 0;
71
+ return {
72
+ name: `adam(lr=${lr})`,
73
+ // returns the update u; caller does W[i] -= u[i]
74
+ step(g) {
75
+ t++;
76
+ const c1 = 1 - Math.pow(b1, t), c2 = 1 - Math.pow(b2, t);
77
+ const u = new Float32Array(dim);
78
+ for (let i = 0; i < dim; i++) {
79
+ m[i] = b1 * m[i] + (1 - b1) * g[i];
80
+ v[i] = b2 * v[i] + (1 - b2) * g[i] * g[i];
81
+ u[i] = lr * (m[i] / c1) / (Math.sqrt(v[i] / c2) + eps);
82
+ }
83
+ return u;
84
+ },
85
+ };
86
+ }
87
+
88
+ const api = { matmul, transpose, forwardLossGrad, applyGrad, averageGrads, makeAdam };
89
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
90
+ else root.TrainCore = api;
91
+ })(typeof self !== "undefined" ? self : this);
public/verified_core.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ // one verified layer forward; returns float out (+ cache for STE backward).
32
+ // Every product goes through the verified INT8 multiply (mul8 LUT) with exact
33
+ // int32 accumulation — i.e. an emulated INT8 tensor-core GEMM — then dequant.
34
+ async function linearFwd(X, W, m, k, n, L, useRelu, matmulInt8) {
35
+ const xq = quantize(X), wq = quantize(W);
36
+ const acc = await (matmulInt8 || lutMatmulJS)(xq.q, wq.q, m, k, n, L); // verified multiply
37
+ const dq = xq.scale * wq.scale;
38
+ const out = new Float32Array(m * n);
39
+ const mask = useRelu ? new Uint8Array(m * n) : null;
40
+ for (let i = 0; i < m * n; i++) {
41
+ let v = acc[i] * dq;
42
+ if (useRelu) { if (v > 0) mask[i] = 1; else v = 0; }
43
+ out[i] = v;
44
+ }
45
+ return { out, mask };
46
+ }
47
+
48
+ // 2-layer MLP: X→H (relu) →dout. Forward through verified units, MSE loss.
49
+ async function forward(X, y, W1, W2, D, L, matmulInt8) {
50
+ const { n, din, h, dout } = D;
51
+ const l1 = await linearFwd(X, W1, n, din, h, L, true, matmulInt8);
52
+ const l2 = await linearFwd(l1.out, W2, n, h, dout, L, false, matmulInt8);
53
+ const resid = new Float32Array(n * dout); let loss = 0;
54
+ for (let i = 0; i < resid.length; i++) { const r = l2.out[i] - y[i]; resid[i] = r; loss += r * r; }
55
+ loss /= resid.length;
56
+ return { loss, resid, z1: l1.out, mask1: l1.mask };
57
+ }
58
+
59
+ // STE backward (verified matmul treated as float X@W). Returns flat [gW1, gW2].
60
+ function backward(X, W1, W2, fwd, D) {
61
+ const { n, din, h, dout } = D;
62
+ const { resid, z1, mask1 } = fwd;
63
+ const s = 2 / n;
64
+ const dout_ = new Float32Array(resid.length);
65
+ for (let i = 0; i < resid.length; i++) dout_[i] = resid[i] * s;
66
+ const mm = TC.matmul, tr = TC.transpose;
67
+ const gW2 = mm(tr(z1, n, h), dout_, h, n, dout); // z1ᵀ @ dout
68
+ const dz1 = mm(dout_, tr(W2, h, dout), n, dout, h); // dout @ W2ᵀ
69
+ for (let i = 0; i < dz1.length; i++) if (!mask1[i]) dz1[i] = 0; // relu grad
70
+ const gW1 = mm(tr(X, n, din), dz1, din, n, h); // Xᵀ @ dz1
71
+ const g = new Float32Array(gW1.length + gW2.length);
72
+ g.set(gW1, 0); g.set(gW2, gW1.length);
73
+ return g;
74
+ }
75
+
76
+ function splitApply(W1, W2, gAvg, lr) {
77
+ for (let i = 0; i < W1.length; i++) W1[i] -= lr * gAvg[i];
78
+ for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
79
+ }
80
+
81
+ const api = { quantize, lutMatmulJS, linearFwd, forward, backward, splitApply };
82
+ if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
83
+ else { TC = root.TrainCore; root.Verified = api; }
84
+ })(typeof self !== "undefined" ? self : this);
public/webgpu.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ (function (root) {
7
+ "use strict";
8
+
9
+ const WGSL = `
10
+ @group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
11
+ @group(0) @binding(1) var<storage, read> Wq : array<i32>;
12
+ @group(0) @binding(2) var<storage, read> lut : array<i32>; // 65536 signed products
13
+ @group(0) @binding(3) var<storage, read_write> C : array<i32>;
14
+ @group(0) @binding(4) var<uniform> dims : vec3<u32>; // m, k, n
15
+ @compute @workgroup_size(8, 8)
16
+ fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
17
+ let m = dims.x; let k = dims.y; let n = dims.z;
18
+ let row = gid.x; let col = gid.y;
19
+ if (row >= m || col >= n) { return; }
20
+ var s : i32 = 0;
21
+ for (var p = 0u; p < k; p = p + 1u) {
22
+ let au = u32(Xq[row * k + p] & 255);
23
+ let bu = u32(Wq[p * n + col] & 255);
24
+ s = s + lut[au * 256u + bu];
25
+ }
26
+ C[row * n + col] = s;
27
+ }`;
28
+
29
+ async function loadLUTs(base) {
30
+ base = base || "";
31
+ const [mulB, reqB, reluB, meta] = await Promise.all([
32
+ fetch(base + "mul_lut.bin").then(r => r.arrayBuffer()),
33
+ fetch(base + "requant_lut.bin").then(r => r.arrayBuffer()),
34
+ fetch(base + "relu_lut.bin").then(r => r.arrayBuffer()),
35
+ fetch(base + "luts_meta.json").then(r => r.json()),
36
+ ]);
37
+ return { mul: new Int16Array(mulB), requant: new Int8Array(reqB),
38
+ relu: new Int8Array(reluB), shift: meta.shift };
39
+ }
40
+
41
+ async function initCompute(L) {
42
+ const cpu = { backend: "cpu", label: "CPU (JS)",
43
+ matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
44
+ if (!(root.navigator && navigator.gpu)) return cpu;
45
+ try {
46
+ const adapter = await navigator.gpu.requestAdapter();
47
+ if (!adapter) return cpu;
48
+ const device = await adapter.requestDevice();
49
+ const module = device.createShaderModule({ code: WGSL });
50
+ const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
51
+ // upload the multiply LUT once (as i32)
52
+ const lut32 = new Int32Array(L.mul); // widen int16 -> int32
53
+ const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
54
+ device.queue.writeBuffer(lutBuf, 0, lut32);
55
+ const info = adapter.info || {};
56
+ return { backend: "webgpu", label: info.description || info.vendor || "WebGPU",
57
+ matmulInt8: (Xq, Wq, m, k, n) => gpuMatmul(device, pipeline, lutBuf, Xq, Wq, m, k, n) };
58
+ } catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
59
+ }
60
+
61
+ async function gpuMatmul(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
62
+ const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
63
+ const bufX = mk(device, X32.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
64
+ const bufW = mk(device, W32.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
65
+ const bytesC = m * n * 4;
66
+ const bufC = mk(device, bytesC, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
67
+ const bufD = mk(device, 16, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
68
+ device.queue.writeBuffer(bufX, 0, X32);
69
+ device.queue.writeBuffer(bufW, 0, W32);
70
+ device.queue.writeBuffer(bufD, 0, new Uint32Array([m, k, n]));
71
+ const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
72
+ { binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
73
+ { binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufC } },
74
+ { binding: 4, resource: { buffer: bufD } } ] });
75
+ const enc = device.createCommandEncoder();
76
+ const pass = enc.beginComputePass();
77
+ pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
78
+ pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8)); pass.end();
79
+ const read = mk(device, bytesC, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
80
+ enc.copyBufferToBuffer(bufC, 0, read, 0, bytesC);
81
+ device.queue.submit([enc.finish()]);
82
+ await read.mapAsync(GPUMapMode.READ);
83
+ const out = new Int32Array(read.getMappedRange().slice(0));
84
+ read.unmap();
85
+ [bufX, bufW, bufC, bufD, read].forEach(b => b.destroy());
86
+ return out;
87
+ }
88
+ function mk(device, size, usage) { return device.createBuffer({ size, usage }); }
89
+
90
+ root.Compute = { initCompute, loadLUTs };
91
+ })(self);
server.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // DaisyChain-Web signaling + static host.
2
+ // - Serves public/ (the page users open).
3
+ // - WebSocket signaling: introduces peers in a room and relays WebRTC
4
+ // offers/answers/ICE. It never sees the compute — that's P2P over WebRTC.
5
+ // Only dependency: `ws`. Run: npm install && node server.js
6
+ const http = require("http");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const crypto = require("crypto");
10
+ const { WebSocketServer } = require("ws");
11
+
12
+ // Snapdrop-style: peers are grouped by their PUBLIC IP, so only devices on the
13
+ // same network auto-discover each other. An explicit ?room=CODE overrides this
14
+ // to connect across networks (share the code with people you invite).
15
+ function clientIP(req) {
16
+ const xff = req.headers["x-forwarded-for"];
17
+ if (xff) return xff.split(",")[0].trim();
18
+ return (req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
19
+ }
20
+ function roomFor(req) {
21
+ const u = new URL(req.url, "http://x");
22
+ const code = u.searchParams.get("room");
23
+ if (code) return "room:" + code;
24
+ const h = crypto.createHash("sha256").update(clientIP(req)).digest("hex").slice(0, 10);
25
+ return "net:" + h; // same network -> same room (IP not exposed in the id)
26
+ }
27
+
28
+ const PORT = process.env.PORT || 8787;
29
+ const PUB = path.join(__dirname, "public");
30
+ const TYPES = { ".html": "text/html", ".js": "text/javascript",
31
+ ".css": "text/css", ".json": "application/json" };
32
+
33
+ const server = http.createServer((req, res) => {
34
+ let p = decodeURIComponent(req.url.split("?")[0]);
35
+ if (p === "/") p = "/index.html";
36
+ const file = path.join(PUB, path.normalize(p));
37
+ if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
38
+ fs.readFile(file, (err, data) => {
39
+ if (err) { res.writeHead(404); return res.end("not found"); }
40
+ res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] || "application/octet-stream" });
41
+ res.end(data);
42
+ });
43
+ });
44
+
45
+ const wss = new WebSocketServer({ server });
46
+ // roomId -> { peers: Map(id -> {ws,name}), host: id|null, pending: Map(id -> {ws,name}) }
47
+ // Private rooms (room:CODE) are gated: the creator is the host, and everyone
48
+ // arriving later waits until the host accepts them — knowing the code is not
49
+ // enough. Network rooms (net:) keep auto-join (same LAN, Snapdrop-style).
50
+ const rooms = new Map();
51
+ let nextId = 1;
52
+
53
+ function send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); }
54
+
55
+ wss.on("connection", (ws, req) => {
56
+ const roomId = roomFor(req);
57
+ const name = (new URL(req.url, "http://x").searchParams.get("name") || "").slice(0, 40) || ("p" + nextId);
58
+ const id = "p" + (nextId++);
59
+ ws.peerId = id; ws.roomId = roomId;
60
+ if (!rooms.has(roomId)) rooms.set(roomId, { peers: new Map(), host: null, pending: new Map() });
61
+ const room = rooms.get(roomId);
62
+ const isPrivate = roomId.startsWith("room:");
63
+
64
+ function admit(pid, peer) {
65
+ const roster = [...room.peers.entries()].map(([qid, v]) => ({ id: qid, name: v.name }));
66
+ send(peer.ws, { type: "welcome", id: pid, room: roomId, peers: roster, host: room.host === pid });
67
+ for (const [, v] of room.peers) send(v.ws, { type: "peer-joined", id: pid, name: peer.name });
68
+ room.peers.set(pid, peer);
69
+ }
70
+
71
+ if (isPrivate && room.peers.size === 0) room.host = id; // creator hosts
72
+ if (isPrivate && room.host !== id) {
73
+ room.pending.set(id, { ws, name });
74
+ send(ws, { type: "waiting" });
75
+ const h = room.peers.get(room.host);
76
+ if (h) send(h.ws, { type: "join-request", id, name });
77
+ } else {
78
+ admit(id, { ws, name });
79
+ }
80
+
81
+ ws.on("message", (buf) => {
82
+ let msg; try { msg = JSON.parse(buf); } catch { return; }
83
+ if (msg.type === "signal" && msg.to && room.peers.has(id)) { // relay WebRTC signaling
84
+ const target = room.peers.get(msg.to);
85
+ if (target) send(target.ws, { type: "signal", from: id, data: msg.data });
86
+ } else if (msg.type === "admit" && id === room.host) { // host verdict on a joiner
87
+ const p = room.pending.get(msg.id);
88
+ if (!p) return;
89
+ room.pending.delete(msg.id);
90
+ if (msg.allow) admit(msg.id, p);
91
+ else { send(p.ws, { type: "denied" }); p.ws.close(); }
92
+ }
93
+ });
94
+
95
+ ws.on("close", () => {
96
+ room.pending.delete(id);
97
+ if (room.peers.delete(id))
98
+ for (const [, v] of room.peers) send(v.ws, { type: "peer-left", id });
99
+ if (room.host === id) { // host left: promote oldest
100
+ room.host = room.peers.keys().next().value ?? null;
101
+ const h = room.peers.get(room.host);
102
+ if (h) {
103
+ send(h.ws, { type: "host" });
104
+ for (const [pid, p] of room.pending) send(h.ws, { type: "join-request", id: pid, name: p.name });
105
+ }
106
+ }
107
+ if (room.peers.size === 0 && room.pending.size === 0) rooms.delete(roomId);
108
+ });
109
+ });
110
+
111
+ server.listen(PORT, () => console.log(`DaisyChain-Web on http://localhost:${PORT}`));
test_core.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Verifies the training loop + 2-peer gradient averaging in pure Node (no
2
+ // browser). Simulates two peers each holding half the data; they average
3
+ // gradients every step. Proves: (a) it converges, (b) both replicas stay
4
+ // identical — the same guarantees the browser P2P version needs.
5
+ const T = require("./public/traincore.js");
6
+
7
+ function randn(n) {
8
+ const a = new Float32Array(n);
9
+ for (let i = 0; i < n; i++) {
10
+ let u = 0, v = 0;
11
+ while (u === 0) u = Math.random();
12
+ while (v === 0) v = Math.random();
13
+ a[i] = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
14
+ }
15
+ return a;
16
+ }
17
+
18
+ const din = 16, dout = 4, nPer = 128, steps = 400, lr = 0.05;
19
+
20
+ // ground-truth weights
21
+ const Wtrue = randn(din * dout);
22
+ function makeShard() {
23
+ const X = randn(nPer * din);
24
+ const y = T.matmul(X, Wtrue, nPer, din, dout); // clean targets
25
+ return { X, y };
26
+ }
27
+ const A = makeShard(), B = makeShard();
28
+
29
+ // both peers start from the SAME W0 (initiator broadcasts it)
30
+ const W0 = randn(din * dout);
31
+ const Wa = Float32Array.from(W0), Wb = Float32Array.from(W0);
32
+
33
+ let loss = 0;
34
+ for (let s = 0; s < steps; s++) {
35
+ const ra = T.forwardLossGrad(A.X, A.y, Wa, nPer, din, dout);
36
+ const rb = T.forwardLossGrad(B.X, B.y, Wb, nPer, din, dout);
37
+ const avg = T.averageGrads([ra.gradW, rb.gradW]); // <-- exchanged P2P
38
+ T.applyGrad(Wa, avg, lr);
39
+ T.applyGrad(Wb, avg, lr);
40
+ loss = (ra.loss + rb.loss) / 2;
41
+ if (s % 80 === 0 || s === steps - 1)
42
+ console.log(` step ${s} cluster-avg loss ${loss.toFixed(6)}`);
43
+ }
44
+
45
+ let maxDiff = 0;
46
+ for (let i = 0; i < Wa.length; i++) maxDiff = Math.max(maxDiff, Math.abs(Wa[i] - Wb[i]));
47
+ let recovery = 0;
48
+ for (let i = 0; i < Wtrue.length; i++) recovery = Math.max(recovery, Math.abs(Wa[i] - Wtrue[i]));
49
+
50
+ console.log(`\nreplica max param diff: ${maxDiff.toExponential(3)}`);
51
+ console.log(`max |W - W_true|: ${recovery.toExponential(3)}`);
52
+ const ok = loss < 1e-3 && maxDiff < 1e-9 && recovery < 0.05;
53
+ console.log(ok ? "\nCORE TEST PASSED — converged, replicas in sync." : "\nCORE TEST FAILED");
54
+ process.exit(ok ? 0 : 1);
test_optimizer.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Verifies DaisyAdam (TrainCore.makeAdam) on the path that matters: training
2
+ // THROUGH the verified INT8 units, where STE gradients are noisy and plain SGD
3
+ // plateaus. Checks (a) two replicas fed the same averaged gradients stay
4
+ // bit-identical, (b) Adam reaches a lower loss than SGD in the same steps.
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const T = require("./public/traincore.js");
8
+ const V = require("./public/verified_core.js");
9
+
10
+ function loadLUTs() {
11
+ const p = (f) => path.join(__dirname, "public", f);
12
+ return {
13
+ mul: new Int16Array(fs.readFileSync(p("mul_lut.bin")).buffer.slice(0)),
14
+ requant: new Int8Array(fs.readFileSync(p("requant_lut.bin")).buffer.slice(0)),
15
+ relu: new Int8Array(fs.readFileSync(p("relu_lut.bin")).buffer.slice(0)),
16
+ };
17
+ }
18
+ function randn(n, rng) { const r = rng || Math.random; const o = new Float32Array(n); for (let i = 0; i < n; i += 2) { let u = 0, v = 0; while (u === 0) u = r(); while (v === 0) v = r(); const m = Math.sqrt(-2 * Math.log(u)); o[i] = m * Math.cos(2 * Math.PI * v); if (i + 1 < n) o[i + 1] = m * Math.sin(2 * Math.PI * v); } return o; }
19
+ 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; }; }
20
+
21
+ const L = loadLUTs();
22
+ const D = { n: 128, din: 16, h: 16, dout: 4 }, steps = 300;
23
+
24
+ const Wtrue1 = randn(D.din * D.h, mulberry32(42)), Wtrue2 = randn(D.h * D.dout, mulberry32(43));
25
+ function target(X) { const hpre = T.matmul(X, Wtrue1, D.n, D.din, D.h); for (let i = 0; i < hpre.length; i++) hpre[i] = Math.max(0, hpre[i]); return T.matmul(hpre, Wtrue2, D.n, D.h, D.dout); }
26
+ const XA = randn(D.n * D.din, mulberry32(11)), yA = target(XA);
27
+ const XB = randn(D.n * D.din, mulberry32(12)), yB = target(XB);
28
+
29
+ async function run(useAdam) {
30
+ const W1a = randn(D.din * D.h, mulberry32(7)), W2a = randn(D.h * D.dout, mulberry32(8));
31
+ const W1b = Float32Array.from(W1a), W2b = Float32Array.from(W2a);
32
+ const dim = W1a.length + W2a.length;
33
+ const oa = T.makeAdam(dim, { lr: 0.2 }), ob = T.makeAdam(dim, { lr: 0.2 });
34
+ let loss = 0;
35
+ for (let s = 0; s < steps; s++) {
36
+ const fa = await V.forward(XA, yA, W1a, W2a, D, L), ga = V.backward(XA, W1a, W2a, fa, D);
37
+ const fb = await V.forward(XB, yB, W1b, W2b, D, L), gb = V.backward(XB, W1b, W2b, fb, D);
38
+ const avg = T.averageGrads([ga, gb]);
39
+ if (useAdam) {
40
+ V.splitApply(W1a, W2a, oa.step(avg), 1);
41
+ V.splitApply(W1b, W2b, ob.step(avg), 1);
42
+ } else {
43
+ V.splitApply(W1a, W2a, avg, 0.03);
44
+ V.splitApply(W1b, W2b, avg, 0.03);
45
+ }
46
+ loss = (fa.loss + fb.loss) / 2;
47
+ }
48
+ let diff = 0;
49
+ for (let i = 0; i < W1a.length; i++) diff = Math.max(diff, Math.abs(W1a[i] - W1b[i]));
50
+ for (let i = 0; i < W2a.length; i++) diff = Math.max(diff, Math.abs(W2a[i] - W2b[i]));
51
+ return { loss, diff };
52
+ }
53
+
54
+ (async function () {
55
+ const sgd = await run(false);
56
+ const adam = await run(true);
57
+ console.log(`SGD(lr=0.03) final loss ${sgd.loss.toFixed(5)}`);
58
+ console.log(`DaisyAdam(lr=0.2) final loss ${adam.loss.toFixed(5)} replica diff ${adam.diff.toExponential(3)}`);
59
+ const ok = adam.diff === 0 && adam.loss < sgd.loss;
60
+ console.log(ok ? "\nOPTIMIZER TEST PASSED — deterministic replicas, beats SGD through the verified units."
61
+ : "\nOPTIMIZER TEST FAILED");
62
+ process.exit(ok ? 0 : 1);
63
+ })();
test_verified.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Proves 2-peer training THROUGH the verified units (LUTs) converges and stays
2
+ // in sync — the browser will do exactly this, with the matmul on WebGPU.
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const T = require("./public/traincore.js");
6
+ const V = require("./public/verified_core.js");
7
+
8
+ function loadLUTs() {
9
+ const p = (f) => path.join(__dirname, "public", f);
10
+ const mul = new Int16Array(fs.readFileSync(p("mul_lut.bin")).buffer.slice(0));
11
+ const requant = new Int8Array(fs.readFileSync(p("requant_lut.bin")).buffer.slice(0));
12
+ const relu = new Int8Array(fs.readFileSync(p("relu_lut.bin")).buffer.slice(0));
13
+ return { mul, requant, relu };
14
+ }
15
+
16
+ function randn(n, rng) { const r = rng || Math.random; const o = new Float32Array(n); for (let i = 0; i < n; i += 2) { let u = 0, v = 0; while (u === 0) u = r(); while (v === 0) v = r(); const m = Math.sqrt(-2 * Math.log(u)); o[i] = m * Math.cos(2 * Math.PI * v); if (i + 1 < n) o[i + 1] = m * Math.sin(2 * Math.PI * v); } return o; }
17
+ 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; }; }
18
+
19
+ const L = loadLUTs();
20
+ const D = { n: 128, din: 16, h: 16, dout: 4 }, lr = 0.03, steps = 300;
21
+
22
+ // shared deterministic truth + init; per-peer data shard
23
+ const Wtrue1 = randn(D.din * D.h, mulberry32(42)), Wtrue2 = randn(D.h * D.dout, mulberry32(43));
24
+ function target(X) { const hpre = T.matmul(X, Wtrue1, D.n, D.din, D.h); for (let i = 0; i < hpre.length; i++) hpre[i] = Math.max(0, hpre[i]); return T.matmul(hpre, Wtrue2, D.n, D.h, D.dout); }
25
+ const XA = randn(D.n * D.din), yA = target(XA);
26
+ const XB = randn(D.n * D.din), yB = target(XB);
27
+
28
+ const W1a = randn(D.din * D.h, mulberry32(7)), W2a = randn(D.h * D.dout, mulberry32(8));
29
+ const W1b = Float32Array.from(W1a), W2b = Float32Array.from(W2a);
30
+
31
+ (async function () {
32
+ let loss = 0, loss0 = 0;
33
+ for (let s = 0; s < steps; s++) {
34
+ const fa = await V.forward(XA, yA, W1a, W2a, D, L), ga = V.backward(XA, W1a, W2a, fa, D);
35
+ const fb = await V.forward(XB, yB, W1b, W2b, D, L), gb = V.backward(XB, W1b, W2b, fb, D);
36
+ const avg = T.averageGrads([ga, gb]); // <-- exchanged P2P
37
+ V.splitApply(W1a, W2a, avg, lr);
38
+ V.splitApply(W1b, W2b, avg, lr);
39
+ loss = (fa.loss + fb.loss) / 2; if (s === 0) loss0 = loss;
40
+ if (s % 60 === 0 || s === steps - 1) console.log(` step ${s} cluster-avg loss ${loss.toFixed(5)}`);
41
+ }
42
+ let diff = 0; for (let i = 0; i < W1a.length; i++) diff = Math.max(diff, Math.abs(W1a[i] - W1b[i]));
43
+ for (let i = 0; i < W2a.length; i++) diff = Math.max(diff, Math.abs(W2a[i] - W2b[i]));
44
+ console.log(`\nreplica max param diff: ${diff.toExponential(3)}`);
45
+ const ok = loss < loss0 * 0.3 && diff < 1e-9;
46
+ console.log(ok ? "VERIFIED TEST PASSED — trained through the units, converged, replicas in sync." : "FAILED");
47
+ process.exit(ok ? 0 : 1);
48
+ })();