[beta] Decentralized sharded WebGPU compute pool - #4
Open
ThyFriendlyFox wants to merge 3 commits into
Open
Conversation
…, pool + beta panel Co-authored-by: ThyFriendlyFox <thyfriendlyfox@gmail.com>
Co-authored-by: ThyFriendlyFox <thyfriendlyfox@gmail.com>
Co-authored-by: ThyFriendlyFox <thyfriendlyfox@gmail.com>
ThyFriendlyFox
marked this pull request as ready for review
June 23, 2026 22:06
ThyFriendlyFox
added a commit
that referenced
this pull request
Jul 31, 2026
AGENTS.md requires mirroring iOS feature work into kotlin/ or recording why not. Phases T (terminal screen), F (package manager) and G (the Node layer) are iOS-only, and until now that was a silent gap in the contract. Measured rather than guessed, so the record is decision-shaped: - T and F are portable work, not blocked work: TerminalScreen (27 KB) and PackageManager (32 KB) are pure logic over Foundation, HTTP and zlib, all of which Android has, and the pyte/pnpm/semver corpora transfer as the Android harness. - G splits 72 % engine-agnostic JS bootstrap (165 KB, ports verbatim) to 28 % host bridge (65 KB of Swift). Android has no public JavaScriptCore, but @JavascriptInterface gives JS synchronous calls into Kotlin — the direction require()/readFileSync/execSync need — so parity is feasible without a third-party engine and without breaking invariant #4. The reverse direction is async, which only reshapes the event loop. Android build re-verified green (gradlew assembleDebug). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox
added a commit
that referenced
this pull request
Aug 5, 2026
Phase T's pure-logic layer in Kotlin, from swift/Mouse/TerminalScreen.swift, TerminalWidth.swift and the contract half of TerminalPrograms.swift. Faithful re-implementation, not a bridge (invariant #5): the grid, the alt-screen save/RESTORE pair, scroll regions, DECCKM, bracketed paste, the 103-range UAX#11 width table, the byte-at-a-time ANSI parser with its query replies, TerminalKey's xterm encodings, and the TerminalProgram/TerminalProgramIO contract. It is its own Gradle module — pure Kotlin/JVM, no Compose, no android.*, no dependency but kotlin-stdlib — for the reason phase T learned on iOS: logic that shares a file with UI is logic no harness can reach. :app depends on it and dexes it (verified: com/reagentsystems/mouse/terminal/TerminalScreen is in classes2.dex). The gate is :screencheck, a main() with no JUnit (invariant #4), shaped like the Swift harnesses in verify/. Its corpus is the iOS corpus ported assertion for assertion — verify/main.swift, altscreen, widechars, widetui and the screen-level half of tty — reading the SAME checked-in fixtures rather than copies, because two platforms gated by different corpora is a parity claim nobody can falsify. That includes the strongest one: the captured claude-code frame rendered against pyte's own output. ./gradlew :screencheck:run SCREEN CORPUS: 182 checks — grid, parser, widths, keys, program contract, pyte frame — MATCH (width table: all 199 agree with Python's unicodedata) Proven able to fail, twice, before it was believed: making TerminalTty.onlcr a pass-through reddened the pyte cross-check with the exact diagonal shear the iOS comment describes ("2 of 182 ... MISMATCH", exit 1), and a bogus width for U+2014 reddened the table ("unicodedata=1 ours=9"). ANDROID_HOME=~/Library/Android/sdk ./gradlew clean :screencheck:run assembleDebug — BUILD SUCCESSFUL. Deliberately deferred, not forgotten: NodeProgram (the Node layer, milestone 3) and the less/top programs. Only TerminalTty.onlcr is lifted out of NodeProgram, because it is tty truth the screen's own gate needs — the iOS comment says as much where it sits. Adaptations where the languages differ, semantics unchanged: a Swift Character (grapheme cluster) is a String, since a Kotlin Char cannot hold an emoji; the parser walks code points for the same reason; value-type array copies are explicit deep copies; CellStyle/TerminalCell are immutable data classes; Sendable and @mainactor have no equivalent and the main-thread discipline is documented instead. One assertion had to be spelled differently: Swift compares Strings by canonical equivalence, so its "combined glyph" expectation is "é"; Kotlin compares code units, so it names what the cell holds, "é". Same grid, stricter comparison. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox
added a commit
that referenced
this pull request
Aug 5, 2026
…stry `PackageManager.swift` ported to `kotlin/packages/`, a pure Kotlin/JVM module with no Android dependency — semver (ranges, caret/tilde, prerelease ordering), the npm registry client, breadth-first resolution with classic hoisting, integrity-checked installs, the node_modules manifest, `npm:` aliases, the wasm substitutions and the napi-rs wasm32-wasi binding, plus `TarGz` (tar, gunzip, raw inflate). The module is its own for the same reason `:terminal` is: the whole npm install path has to be gatable without an emulator. Two things follow from that: - `TarGz` moved out of `Workspace.kt` into `:packages`, exactly as iOS moved it out of `Workspace.swift` — the clone and the installer share one reader, and the shared one must be reachable from a harness. It gains what the Swift one has and the app copy lacked: GNU base-256 sizes, symlinks, a path-traversal guard, and pax `x` told apart from global `g`. - The module brings its own JSON. `org.json` is in the Android framework but not the JDK, so a pure-JVM module using it would cost a third-party artifact (invariant #4) and stop building off-device. The gate is `./gradlew :pkgcheck:run` — a `main()` printing one verdict line, no JUnit, the same shape as `:screencheck` and the Swift harnesses. The corpus is the iOS one (`verify/pkg`, `verify/npmalias`, the resolution half of `verify/napiwasi`): the semver corpus, resolveTree against a real `pnpm install --lockfile-only`, and real installs of chalk/glob/mkdirp proven by real `node` requiring out of the tree. 94 checks, MATCH. Evidence: - `:pkgcheck:run` — 94 checks MATCH (5 pnpm resolutions agree package-for- package; real node loads chalk and glob from our layout) - `:screencheck:run` — 182 checks MATCH, unregressed - `ANDROID_HOME=~/Library/Android/sdk ./gradlew assembleDebug` — clean - the gate was proven to fail twice before being believed: `^`'s upper bound bumped by one reddened 9 checks (semver, three pnpm resolutions, both real-node layout proofs, both alias legs), and stubbing the integrity comparison to `if (false)` reddened the corruption check on its own. Both reverted. One finding recorded in STATUS.md, not fixed here (iOS is frozen for this loop): `verify/napiwasi` has gone stale for a reason that is not ours — neither rolldown nor oxc-parser publishes a `…-wasm32-wasi` optional dependency any more. The Kotlin gate therefore asserts the RULE on a synthetic napi-rs-shaped package and keeps one live-registry leg on a package that still ships one. Deferred to milestone 3 with the Node layer: `npm install`/`npx`/`npm run` in Kotlin msh. They exist to RUN what they install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox
added a commit
that referenced
this pull request
Aug 5, 2026
`NodeSockets.swift` becomes `kotlin/node/.../NodeSockets.kt`: one selector thread owning every channel, not a thread per socket, because "a dev server with 50 keep-alive connections must not cost 50 threads" is sharper on Android, not weaker. The load-bearing rules came across with the reasoning rather than the code — EOF is not a close, a half-open socket stays writable, an accepted socket never exists without a handler, nothing may block the socket queue. `NodeDns.kt` writes and parses DNS on the wire, including the name decompression `res_9_dn_expand` does for iOS: Android ships no JNDI, so a resolver built on `javax.naming` would gate green off-device and be absent on a phone. The nameservers come from the HOST (`ConnectivityManager`), because Android has no /etc/resolv.conf and guessing a public resolver would be a lie about the user's network. `NodeHttp.kt` is the TLS-capable transport behind `fetch` and `https.request` — `HttpURLConnection` where iOS has URLSession, same bargain, and delivered INCREMENTALLY so the head arrives before the body. Moved out of `HostBridge.DEFERRED`: netConnect/Listen/Write/End/Destroy/ Pause/Resume/Ref/NoDelay/KeepAlive/Resolve, dnsResolve/Reverse/Service/Done, httpRequest/httpStream, dgramBind/Send/Option/Membership. Still deferred, each with a reason that is true NOW rather than inherited: unix-domain sockets (AF_UNIX is `UnixDomainSocketAddress`, API 34, against minSdk 26), the cluster descriptor handoff (java.nio will not adopt an fd it did not open), and the WebSocket global (no WebSocket client in the JDK or the framework, and invariant #4 forbids adding one — the `ws` PACKAGE rides these sockets and works). `fsWatch`'s reason was edited too: its "the socket layer does not exist yet" clause stopped being true today. The bridge partition did its job on the way in. `NodeSmoke` asserted that `netConnect` REFUSES; the moment sockets were real that became an assertion that a regression had happened, and the gate said so by name. Evidence: :screencheck SCREEN CORPUS: 202 checks — MATCH :pkgcheck PACKAGE MANAGER: 140 checks — MATCH :nodecheck NODE LAYER: 421 checks — MATCH (was 310) assembleDebug clean git status --porcelain swift | wc -l → 0 What the 111 new checks are: * `NodeSockets` driven directly against REAL peers — a plain JVM socket for the deterministic cases and real `node` for the two that matter: a node CLIENT against our server and our client against a node SERVER. "It works when both ends are ours" proves nothing about the wire. * the DNS wire format against a message built byte by byte WITH compression pointers (no JVM has a reference `dn_expand`), plus resolve{Txt,Mx,Ns} against real node's answers for the same live names. * the TLS transport's ORDER of events, not only its bytes — head,data,data, end. A fixture comparing the concatenated body passes just as happily against a transport that buffers everything. * `verify/neterrors` and `verify/reqsock`, read out of `verify/` and graded against the SAME checked-in node.txt iOS is graded against. Both match byte for byte. * `NodeSocketSmoke` — the shared program the DEVICE gate also runs. One real bug the gate found: a server's handler was dropped on the server's own close while its accepted sockets still routed through it, so `server.close(cb)` never called back. A handler now goes only when the owner AND every socket routed through it are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox
added a commit
that referenced
this pull request
Aug 5, 2026
Digests, HMAC, PBKDF2 and `randomUUID`. CryptoKit on iOS, the JCA here — the platform's own either way, so invariant #4 holds without argument. `NodeCrypto` is in `:node` rather than `:app` because none of it is framework: `MessageDigest`, `Mac` and `SecretKeyFactory` are JDK classes Android also ships, so it runs in a bare JVM harness. That is what lets it be graded against REAL NODE computing the same digests rather than against pinned vectors, and the difference matters here more than usual. A pinned vector proves an algorithm was implemented; it does not prove OUR SPELLING reaches the same function, and the spellings are the whole of the work: * node says `sha256`, CryptoKit says `SHA256`, the JCA says `SHA-256` — and its HMAC names have no separator at all. * `sha224` exists in the JCA and not in CryptoKit. * an empty HMAC key is legal to node and REJECTED by the JCA. It is padded to one zero byte here, which is exact rather than approximate: HMAC's inner padding makes a zero-length key and a single zero byte the same input. * `PBEKeySpec` takes CHARS, so a password byte over 0x7f would go through a charset and come out corrupted. It is mapped latin-1, the one encoding where a byte round-trips through a char. 53 checks, every message × every digest, HMAC across three key lengths including empty, and PBKDF2 at 1000 rounds — each compared with what real node answers for the same input. Proven able to fail: removing the empty-key padding fails six of them by name. The gate also caught a stale assertion on the way in, which is the third time this pattern has fired: `NodeSmoke` probed `cryptoHash` as a DEFERRED surface, so implementing it turned that check into an assertion that a regression had happened. It probes `zlibOpen` now, and its comment says the name is expected to keep moving — 3c did this to `netConnect`, 3d to `cryptoHash`. STILL DEFERRED, and the line is not arbitrary. What remains needs KEY MANAGEMENT — ciphers, EC/RSA/Ed25519 signing, ECDH, key generation — where iOS rides Security framework and the JCA has no one-to-one counterpart, so it is a port rather than a translation. Compression, `vm`, workers and children are untouched. :screencheck 238 checks MATCH :pkgcheck 140 checks MATCH :nodecheck 483 checks MATCH (430 + 53) :shellcheck 25 scripts MATCH /bin/sh on-device 66 checks MATCH assembleDebug clean swift/ untouched On device, against real node's own answers for the same input: ~ $ node hash.js sha256 b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 hmac 0ba06f1f9a6300461e43454535dc3c42 uuid d7248d5c-bdf4-408a-bd7e-991e10028c39 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox
added a commit
that referenced
this pull request
Aug 5, 2026
iOS drives zlib's own `z_stream`, where `windowBits` selects the framing for
free: 15 is the zlib wrapper, −15 raw, 15+16 gzip, 15+32 "whichever this is".
`java.util.zip` exposes exactly one of those choices — `Deflater(level,
nowrap)` — so the rest is written here:
* gzip's 10-byte header and its CRC32/ISIZE trailer, little-endian, over the
UNCOMPRESSED bytes. `GZIPOutputStream` would cover the one-shot case and
cannot do it incrementally, so both paths share this framing rather than
being two implementations that could disagree.
* the auto-detect that `gunzip`, `inflate` and `unzip` all need, since iOS
gives all three 15+32. Two bytes decide it, and a streaming coder that has
only seen one HOLDS it rather than guessing — picking the wrong framing on
one byte would be wrong for the whole stream.
* SYNC_FLUSH while a deflating stream is live, or a chunk can sit inside the
coder and a caller that pushed data and got nothing back reads it as a
stall.
GRADED BOTH DIRECTIONS, which is the point: node decompresses what we compress
AND we decompress what node compressed. A round-trip through our own code
proves self-consistency, not correctness — a wrong frame passes it happily
while producing bytes no other implementation accepts. Proven exactly that
way: corrupting the gzip CRC fails only the cross-engine checks, because our
own inflater ignores the trailer. 50 checks, one-shot and streaming.
The stale-probe pattern fired a fourth time — `NodeSmoke` was probing
`zlibOpen` as deferred. It probes `brotliOpen` now, and that one is the LAST of
its kind here: brotli is not waiting on a milestone.
Brotli's refusal was also REWRITTEN, because the old one was no longer true.
It said "the Android host binds neither, and java.util.zip covers only half" —
half of which has just stopped being true. The reason now names the real wall:
Android decodes brotli inside its HTTP stack and exposes no codec to an app,
`java.util.zip` is DEFLATE only, and hand-writing it means carrying the static
dictionary too. A third-party artifact is what invariant #4 forbids.
:screencheck 238 checks MATCH
:pkgcheck 140 checks MATCH
:nodecheck 533 checks MATCH (483 + 50)
:shellcheck 25 scripts MATCH /bin/sh
on-device 66 checks MATCH
assembleDebug clean
swift/ untouched
On device, against real node running the same script:
~ $ node gz.js
plain 1000 gzip 49 ← real node: plain 1000 gzip 49
roundtrip true
magic 1f 8b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A beta scaffold for a decentralized, sharded WebGPU compute pool: many browser GPUs cooperating to run inference-style workloads, contributed voluntarily to form a shared pool. Off by default behind a feature flag and fully isolated from the existing app.
How it works
Large-model inference is dominated by big matrix multiplies (the core transformer-layer op). The pool splits each matmul into row-block shards and spreads them across every participating GPU, then reassembles the result. Each node is both a worker (computes shards for others) and a requester (dispatches its own jobs).
What's included (
src/compute/)webgpu.tsShardScheduler.tssignaling.tspeer.tsPeerLink— a peer as a remoteWorkeroverRTCDataChannelComputePool.tsrunSharded()/runDemo()ComputePoolView.tstypes.tsPlus
compute-coordinator/— a tiny reference signaling/membership broker (relays WebRTC handshakes only; shard payloads go peer-to-peer).Enabling
A ⚡ launcher then opens the panel. With no coordinator the pool runs as a single node ("solo") and still demonstrates the full shard → compute → merge pipeline on one GPU.
Testing
npm run build(tsc + vite) passes; the compute module is isolated and the app is unchanged when the flag is off.peer_joined, signal relay, andpeer_leftall pass.Beta limitations (see
src/compute/README.md)