// DaisyChain-Web client: connect P2P (WebRTC), compute (WebGPU or CPU), and // train a shared model together — averaging gradients over the data channels. "use strict"; // model + task now live in transformer.js — a mini transformer LM trained // through the verified INT8 units. Settings come from the sliders. // STUN discovers the direct path; the TURN relays (Open Relay, free) carry the // traffic when both sides sit behind symmetric NATs (mobile carriers, corp // networks) — without TURN those users can never connect across networks. const STUN = [ { urls: "stun:stun.l.google.com:19302" }, { urls: "turn:openrelay.metered.ca:80", username: "openrelayproject", credential: "openrelayproject" }, { urls: "turn:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" }, { urls: "turns:openrelay.metered.ca:443?transport=tcp", username: "openrelayproject", credential: "openrelayproject" }, ]; let rtcConfig = { iceServers: STUN }; // /mode can override (own TURN etc.) const ui = { status: document.getElementById("status"), backend: document.getElementById("backend"), me: document.getElementById("me"), peers: document.getElementById("peers"), loss: document.getElementById("loss"), step: document.getElementById("step"), bar: document.getElementById("bar"), diff: document.getElementById("diff"), log: document.getElementById("log"), start: document.getElementById("start"), save: document.getElementById("save"), load: document.getElementById("load"), loadBtn: document.getElementById("loadBtn"), requests: document.getElementById("requests"), roomInfo: document.getElementById("roomInfo"), roomCode: document.getElementById("roomCode"), copyLink: document.getElementById("copyLink"), cfgC: document.getElementById("cfgC"), cfgT: document.getElementById("cfgT"), cfgB: document.getElementById("cfgB"), cfgSteps: document.getElementById("cfgSteps"), cfgLr: document.getElementById("cfgLr"), dataset: document.getElementById("dataset"), tokenizer: document.getElementById("tokenizer"), kit: document.getElementById("kit"), genPrompt: document.getElementById("genPrompt"), genBtn: document.getElementById("genBtn"), genOut: document.getElementById("genOut"), }; function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${m}\n` + ui.log.textContent; } function setStatus(s) { ui.status.textContent = s; } // ---- deterministic RNG so every peer agrees on W_true and W0 (no broadcast) -- 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; }; } 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; } // ---- state ----------------------------------------------------------------- // friendly device name (cottagecore, Snapdrop-style) const ADJ = ["Mossy", "Golden", "Amber", "Fern", "Hazel", "Cozy", "Wandering", "Little", "Sunny", "Misty", "Wild", "Quiet", "Brave", "Dusty", "Merry"]; const NOUN = ["Fox", "Hare", "Owl", "Badger", "Toad", "Sparrow", "Otter", "Deer", "Hedgehog", "Mushroom", "Acorn", "Willow", "Robin", "Fawn", "Moth"]; const deviceName = ADJ[Math.floor(Math.random() * ADJ.length)] + " " + NOUN[Math.floor(Math.random() * NOUN.length)]; let myId = null, compute = null, ws = null, L = null, wasDenied = false; const pcs = new Map(), chans = new Map(); // peerId -> RTCPeerConnection / DataChannel const names = new Map(); // peerId -> device name const incoming = new Map(); // step -> Map(peerId -> Float32Array) let model = null, training = false; // the mini transformer (transformer.js) let trainedSteps = 0; // steps baked into the current weights // ---- sync guard state ------------------------------------------------------- // The peer that presses Start is the round leader. Every step the leader // publishes the exact set of contributors to average (the roster); followers // apply that set verbatim or stop — no device may silently average a // different set, which is what used to fork the weights. let leaderId = null; // null => I lead (I pressed Start) const rosters = new Map(); // step -> [peerId,...] from the leader const peerHashes = new Map(); // step -> Map(peerId -> uint32 pre-step weight hash) function nmeOf(id) { return names.get(id) || id; } function room() { return new URLSearchParams(location.search).get("room"); } // null -> group by network // show EVERY device the room knows about, not just open data channels — // a peer whose WebRTC is still connecting (or failed) must stay visible function updatePeers() { if (!names.size) { ui.peers.textContent = "(none yet)"; return; } ui.peers.textContent = [...names.entries()] .map(([id, n]) => `${n} ${chans.has(id) ? "✓" : "(connecting…)"}`).join(", "); } // ---- signaling + WebRTC ---------------------------------------------------- function connectSignaling() { const proto = location.protocol === "https:" ? "wss" : "ws"; const params = new URLSearchParams(); params.set("name", deviceName); if (room()) params.set("room", room()); // no code -> group by network ws = new WebSocket(`${proto}://${location.host}/?${params}`); ws.onopen = () => setStatus(room() ? `connected — private room "${room()}"` : "connected — grouping with devices on your network"); ws.onclose = () => { if (wasDenied) return; setStatus("signaling disconnected — reconnecting…"); setTimeout(connectSignaling, 3000); // data channels survive; rejoin the room }; ws.onmessage = async (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "welcome") { myId = msg.id; if (msg.room) log(`group: ${msg.room.startsWith("net:") ? "your network" : "private room " + msg.room.replace("room:", "")}`); if (msg.host) { log("you host this room — joiners wait for your approval"); setStatus(`hosting private room "${room()}"`); } else if (room()) setStatus(`accepted into private room "${room()}"`); // I'm newest: initiate to everyone already here (skip channels that // survived a signaling blip — no duplicate connections) for (const p of msg.peers) { names.set(p.id, p.name); if (!chans.has(p.id)) initiatePeer(p.id); } updatePeers(); } else if (msg.type === "waiting") { setStatus("knocking — waiting for the room's host to let you in…"); } else if (msg.type === "denied") { wasDenied = true; setStatus("the host declined your request to join"); log("join request declined by the host"); } else if (msg.type === "host") { log("the host left — you are now the host of this room"); setStatus(`hosting private room "${room()}"`); } else if (msg.type === "join-request") { addJoinRequest(msg.id, msg.name); } else if (msg.type === "peer-joined") { names.set(msg.id, msg.name); updatePeers(); log(`${msg.name} joined (they will connect to me)`); } else if (msg.type === "peer-left") { log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); updatePeers(); } else if (msg.type === "signal") { await onSignal(msg.from, msg.data); } else if (msg.type === "ping") { // keepalive (proxies close idle sockets) ws.send(JSON.stringify({ type: "pong" })); } else if (msg.type === "relay") { // payload relayed via the server (WS fallback) onGrad(msg.from, bufFromB64(msg.data)); } }; } function signal(to, data) { ws.send(JSON.stringify({ type: "signal", to, data })); } // host-side Accept/Deny row for a knocking device (textContent only — the name // comes off the wire and must never be parsed as HTML) function addJoinRequest(id, name) { const row = document.createElement("div"); row.style.cssText = "display:flex;gap:8px;align-items:center;justify-content:space-between;margin-top:10px;flex-wrap:wrap"; const who = document.createElement("span"); who.textContent = `🚪 ${name} wants to join`; const btn = (label, allow) => { const b = document.createElement("button"); b.textContent = label; b.style.cssText = "padding:6px 14px;font-size:.85rem" + (allow ? "" : ";background:#8b2e25"); b.onclick = () => { ws.send(JSON.stringify({ type: "admit", id, allow })); row.remove(); log(allow ? `you let ${name} in` : `you declined ${name}`); }; return b; }; row.append(who, btn("Accept", true), btn("Deny", false)); ui.requests.appendChild(row); } function newPC(peerId) { const pc = new RTCPeerConnection(rtcConfig); pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); }; pc.onconnectionstatechange = () => { if (pc.connectionState === "failed") { cleanupPeer(peerId); scheduleReconnect(peerId); } // "disconnected" often self-heals; give it a grace period, then treat as failed if (pc.connectionState === "disconnected") setTimeout(() => { if (pcs.get(peerId) === pc && pc.connectionState === "disconnected") { log(`${nmeOf(peerId)} connection did not recover — redialing`); cleanupPeer(peerId); scheduleReconnect(peerId); } }, 4000); }; pcs.set(peerId, pc); return pc; } // ---- auto-reconnect ---------------------------------------------------------- // If a data channel drops but the peer is still in the room (signaling knows // them), redial with backoff. Only the higher-numbered peer initiates, so both // sides don't collide (offer glare). If the peer truly left, names loses them // and the retries stop on their own. // ---- WebSocket relay fallback (PairDrop's WSPeer pattern) --------------------- // A peer still in the room after 5 failed WebRTC attempts is reachable through // the signaling server even though no direct path exists (symmetric NATs, no // TURN). Install a pseudo data channel that relays binary over the WS instead // of dropping them. Devices that actually left are removed via peer-left. function b64FromBuf(buf) { const u = new Uint8Array(buf); let s = ""; for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); } function bufFromB64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; } function makeRelayChannel(peerId) { return { isRelay: true, get readyState() { return ws && ws.readyState === 1 && names.has(peerId) ? "open" : "closed"; }, get bufferedAmount() { return ws ? ws.bufferedAmount : 0; }, send(buf) { ws.send(JSON.stringify({ type: "relay", to: peerId, data: b64FromBuf(buf) })); }, close() { chans.delete(peerId); }, }; } const reconnectTimers = new Map(); // peerId -> attempt count function scheduleReconnect(peerId, attempt = 0) { if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return; if (attempt >= 5) { // no direct path — fall back to server relay if (names.has(peerId)) { log(`no direct WebRTC path to ${nmeOf(peerId)} after 5 attempts — relaying through the server instead`); chans.set(peerId, makeRelayChannel(peerId)); updatePeers(); wake(); ui.start.disabled = false; } return; } const delay = 1500 * Math.pow(2, attempt); reconnectTimers.set(peerId, setTimeout(() => { reconnectTimers.delete(peerId); if (!names.has(peerId) || chans.has(peerId)) return; if (+myId.slice(1) > +peerId.slice(1)) { // deterministic initiator log(`reconnecting to ${nmeOf(peerId)} (attempt ${attempt + 1})…`); cleanupPeer(peerId); initiatePeer(peerId); } setTimeout(() => scheduleReconnect(peerId, attempt + 1), 6000); }, delay)); } function initiatePeer(peerId) { const pc = newPC(peerId); const dc = pc.createDataChannel("daisy"); setupChannel(peerId, dc); pc.createOffer().then(o => pc.setLocalDescription(o)).then(() => signal(peerId, { sdp: pc.localDescription })); } // ICE candidates can arrive while setRemoteDescription is still awaiting (the // async handlers interleave) — adding one before the description is set throws // and the candidate is lost, so the connection silently fails. Queue them and // flush once the remote description lands. const pendingCand = new Map(); // peerId -> [candidate,...] async function onSignal(from, data) { let pc = pcs.get(from); if (data.sdp) { if (!pc) { pc = newPC(from); pc.ondatachannel = (e) => setupChannel(from, e.channel); } await pc.setRemoteDescription(data.sdp); for (const c of pendingCand.get(from) || []) try { await pc.addIceCandidate(c); } catch (e) {} pendingCand.delete(from); if (data.sdp.type === "offer") { const ans = await pc.createAnswer(); await pc.setLocalDescription(ans); signal(from, { sdp: pc.localDescription }); } } else if (data.candidate) { if (pc && pc.remoteDescription) { try { await pc.addIceCandidate(data.candidate); } catch (e) {} } else { // pc missing or not ready: hold it if (!pendingCand.has(from)) pendingCand.set(from, []); pendingCand.get(from).push(data.candidate); } } } function setupChannel(peerId, dc) { dc.binaryType = "arraybuffer"; dc.onopen = () => { chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); ui.start.disabled = false; // I'm leading a live run (leaderId null while training) — sync them in at // the next step boundary instead of making them wait for the next run if (training && leaderId === null) { pendingJoins.add(peerId); log(`${nmeOf(peerId)} will be synced into the running training`); } }; dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); }; dc.onmessage = (e) => onGrad(peerId, e.data); } function cleanupPeer(id) { const pc = pcs.get(id); if (pc) pc.close(); pcs.delete(id); chans.delete(id); pendingCand.delete(id); pendingJoins.delete(id); for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k); wake(); } // ---- checkpoints ------------------------------------------------------------ // File layout (also the broadcast payload after the sentinel): // 8 bytes magic "DAISYPT2" | int32 c,t,vocab,steps | f32 flat params // DaisyChain's own format (not torch-pickle) — .pt extension for familiarity. const CKPT_MAGIC = "DAISYPT2"; const CKPT_SENTINEL = -2; // wire: [int32 -2][checkpoint bytes] function packCheckpoint() { const flat = Transformer.getFlatParams(model); const buf = new ArrayBuffer(8 + 16 + flat.length * 4); new Uint8Array(buf, 0, 8).set([...CKPT_MAGIC].map(c => c.charCodeAt(0))); new Int32Array(buf, 8, 4).set([model.cfg.c, model.cfg.t, model.cfg.vocab, trainedSteps]); new Float32Array(buf, 24).set(flat); return buf; } function parseCheckpoint(buf) { const magic = String.fromCharCode(...new Uint8Array(buf, 0, 8)); if (magic !== CKPT_MAGIC) throw new Error("not a DaisyChain v2 checkpoint"); const [c, t, vocab, steps] = new Int32Array(buf, 8, 4); if (vocab !== Transformer.vocabSize()) throw new Error(`tokenizer mismatch — checkpoint has a ${vocab}-token vocab, this device loaded ` + `${Transformer.vocabSize()} (${Transformer.tokenizerName()}); use matching builds`); if (c < 16 || c > 128 || t < 16 || t > 128) throw new Error(`bad dims in checkpoint (width ${c}, seq ${t})`); return { c, t, steps, flat: new Float32Array(buf.slice(24)) }; } function applyCheckpoint(ck, from) { if (!model || model.cfg.c !== ck.c || model.cfg.t !== ck.t) { buildModel({ ...readCfgFromUI(), c: ck.c, t: ck.t }); showCfgInUI({ ...readCfgFromUI(), c: ck.c, t: ck.t }); } if (ck.flat.length !== model.nParams) throw new Error("truncated checkpoint"); Transformer.setFlatParams(model, ck.flat); trainedSteps = ck.steps; modelReady(); ui.step.textContent = `${ck.steps} baked in`; log(`checkpoint loaded (${ck.steps} steps) ${from ? "from " + from : "from file"} — all set to resume`); } function broadcastCheckpoint() { const ck = packCheckpoint(); const msg = new ArrayBuffer(4 + ck.byteLength); new Int32Array(msg, 0, 1)[0] = CKPT_SENTINEL; new Uint8Array(msg, 4).set(new Uint8Array(ck)); let n = 0; for (const dc of chans.values()) if (dc.readyState === "open") { dcSend(dc, msg); n++; } log(`checkpoint pushed to ${n} device(s) (${(msg.byteLength / 1048576).toFixed(1)} MB)`); } // ---- inference kit: one self-contained HTML file with the trained weights -- // Bundles the model code + current checkpoint (base64) + a prompt box into a // single file that runs generations offline — no server, nothing to install. // The mul8 LUT is rebuilt in the file itself (it is exactly a×b for int8). async function downloadInferenceKit() { const srcs = await Promise.all(["traincore.js", "verified_core.js", "transformer.js"] .map(f => fetch(f).then(r => r.text()))); let tokData = "null"; // embed the tokenizer (offline file) try { tokData = await (await fetch("tokenizer.json")).text(); } catch (e) {} const ck = new Uint8Array(packCheckpoint()); let b64 = ""; const CH = 0x8000; for (let i = 0; i < ck.length; i += CH) b64 += String.fromCharCode(...ck.subarray(i, i + CH)); b64 = btoa(b64); const esc = (s) => s.replace(/<\/script/gi, "<\\/script"); const html = `
Trained ${trainedSteps} steps · width ${model.cfg.c} · seq ${model.cfg.t} · dataset: ${Transformer.datasetName()}. Runs entirely in this file through the verified INT8 units (CPU LUT).