diff --git a/.github/workflows/pta-web.yml b/.github/workflows/pta-web.yml new file mode 100644 index 0000000..a38cbaa --- /dev/null +++ b/.github/workflows/pta-web.yml @@ -0,0 +1,115 @@ +# pta-web.yml -- the canary's PTA harness as a browser module, checked and published. +# +# tools/pta-web/build.sh builds the harness pinned in docs/designs/canary/harness +# with gcc and with emcc; tools/pta-web/compare.sh runs the canary's rows +# natively, as a node build and as the browser module itself, and fails unless +# all three print the same bytes. +# +# On main (and on demand) a passing build is force-pushed as one commit to the +# orphan branch `pta-web`: the module, the canary network, the pooled data, +# rows.json (the canary's settings and what the harness printed for them) and +# manifest.json (source commit, harness digests, compilers, file digests). +# A page can load it from raw.githubusercontent.com with no token. A build that +# does not match the harness is never published. +# +# Pull requests build and compare, and publish nothing. +name: PTA web module + +on: + push: + branches: [main] + paths: + - 'docs/designs/canary/**' + - 'tools/pta-web/**' + - '.github/workflows/pta-web.yml' + pull_request: + paths: + - 'docs/designs/canary/**' + - 'tools/pta-web/**' + - '.github/workflows/pta-web.yml' + workflow_dispatch: + +permissions: + contents: write # push the pta-web branch + +concurrency: + group: pta-web-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Emscripten + uses: mymindstorm/setup-emsdk@v14 + with: + version: 3.1.74 + + - name: Build natively and as WebAssembly + run: bash tools/pta-web/build.sh "$RUNNER_TEMP/pta-web" + + - name: Compare native, node build and browser module + run: bash tools/pta-web/compare.sh "$RUNNER_TEMP/pta-web" + + - name: Summary + if: always() + run: | + f="$RUNNER_TEMP/pta-web/summary.md" + [ -f "$f" ] && cat "$f" >> "$GITHUB_STEP_SUMMARY" || echo "no summary written" >> "$GITHUB_STEP_SUMMARY" + + - name: Keep the build and the runs + if: always() + uses: actions/upload-artifact@v4 + with: + name: pta-web + retention-days: 30 + if-no-files-found: ignore + path: | + ${{ runner.temp }}/pta-web/web + ${{ runner.temp }}/pta-web/runs + ${{ runner.temp }}/pta-web/summary.md + + # Only after compare passed (no always()), and never from a pull request. + - name: Publish to the pta-web branch + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + run: | + set -eu + web="$RUNNER_TEMP/pta-web/web" + cp "$RUNNER_TEMP/pta-web/summary.md" "$web/" + python3 - "$web" <<'PY' + import hashlib, json, os, subprocess, sys, datetime + web = sys.argv[1] + sha = lambda p: hashlib.sha256(open(p, 'rb').read()).hexdigest() + harness = 'docs/designs/canary/harness' + doc = dict( + repo=os.environ['GITHUB_REPOSITORY'], commit=os.environ['GITHUB_SHA'], + run_url=f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}", + generated_at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'), + emcc=subprocess.run(['emcc', '--version'], capture_output=True, text=True).stdout.splitlines()[0], + gcc=subprocess.run(['gcc', '--version'], capture_output=True, text=True).stdout.splitlines()[0], + harness={f: sha(os.path.join(harness, f)) for f in sorted(os.listdir(harness))}, + files={f: dict(bytes=os.path.getsize(os.path.join(web, f)), sha256=sha(os.path.join(web, f))) + for f in sorted(os.listdir(web)) if f != 'manifest.json'}, + identical_to_native=True, + ) + json.dump(doc, open(os.path.join(web, 'manifest.json'), 'w'), indent=2) + PY + cat > "$web/README.md" <&2; exit 1; } +done <<'DIGESTS' +ba891046e6505d7aadcbbe25680a0738ad16aec93bde7f9b65e87a2fc25776db train-images-idx3-ubyte +65a50cbbf4e906d70832878ad85ccda5333a97f0f4c3dd2ef09a8a9eef7101c5 train-labels-idx1-ubyte +0fa7898d509279e482958e8ce81c8e77db3f2f8254e26661ceb7762c4d494ce7 t10k-images-idx3-ubyte +ff7bcfd416de33731a308c3f266cc351222c34898ecbeaf847f06e48f7ec33f2 t10k-labels-idx1-ubyte +DIGESTS + +echo "== native build" +gcc --version | head -1 +gcc $CFLAGS -o "$OUT/pta_mnist" "$SRC/pta_mnist.c" "$SRC/pta_tile_model.c" -lm +"$OUT/pta_mnist" selftest + +echo "== data, pooled to 8 x 8 by the harness itself" +for f in train-images-idx3-ubyte train-labels-idx1-ubyte t10k-images-idx3-ubyte t10k-labels-idx1-ubyte; do + gzip -dc "$OUT/mnist/$f.gz" > "$OUT/data/$f" + case $f in *images*) "$OUT/pta_mnist" pool "$OUT/data/$f" "$OUT/data/$f" > /dev/null ;; esac +done +echo chain > "$OUT/data/shape"; echo as-is > "$OUT/data/pixels" + +echo "== WebAssembly builds" +emcc --version | head -1 +# For node: the same command line, with the real filesystem, so it reads exactly what gcc's does. +emcc $CFLAGS -sALLOW_MEMORY_GROWTH=1 -sNODERAWFS=1 -sEXIT_RUNTIME=1 \ + -o "$OUT/pta_mnist_node.js" "$SRC/pta_mnist.c" "$SRC/pta_tile_model.c" -lm +# For the browser: a module the page runs in a worker, data in its in-memory filesystem. +# Node is allowed too, so compare.sh can run this exact file, not just a sibling build. +emcc $CFLAGS -O3 -sALLOW_MEMORY_GROWTH=1 -sMODULARIZE=1 -sEXPORT_NAME=PtaMnist \ + -sENVIRONMENT=web,worker,node -sINVOKE_RUN=0 -sEXIT_RUNTIME=0 \ + -sEXPORTED_RUNTIME_METHODS=callMain,FS \ + -o "$OUT/web/pta_mnist.js" "$SRC/pta_mnist.c" "$SRC/pta_tile_model.c" -lm +cp "$ROOT/docs/designs/canary/canary_seed1.net" "$OUT/web/" +for f in train-images-idx3-ubyte train-labels-idx1-ubyte t10k-images-idx3-ubyte t10k-labels-idx1-ubyte; do + gzip -9c "$OUT/data/$f" > "$OUT/web/$f.gz" +done + +echo "== sizes" +( cd "$OUT/web" && for f in *; do printf '%-34s %9d bytes gzip %9d\n' "$f" "$(wc -c < "$f")" "$(gzip -9c "$f" | wc -c)"; done ) diff --git a/tools/pta-web/compare.sh b/tools/pta-web/compare.sh new file mode 100755 index 0000000..5abeda6 --- /dev/null +++ b/tools/pta-web/compare.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Gridworkz +# compare.sh -- run canary settings natively, as the node build and as the +# browser module itself; demand the same bytes from all three. +# +# The rows are the canary's own (pta_mnist.sh, `canary`): seed 1, draw 0, the +# 128 x 64 tile, the gate's first thousand images, the scale from a thousand +# held-out ones. A browser demo is only worth building if its numbers are the +# harness's numbers, so any difference fails this, and the table says how long +# each took both ways. +# +# Usage: tools/pta-web/compare.sh OUT_DIR (after build.sh; needs node) +set -u +OUT=$1 +HERE=$(cd "$(dirname "$0")" && pwd) +NET="$(cd "$HERE/../.." && pwd)/docs/designs/canary/canary_seed1.net" +g="--rows 128 --cols 64" +q="--abits 6 --adcbits 8" +r="$q --thermal8 0.25 --photons8 30 --prog 1 --xtalk 0.02" +t="--host 2 --probe 1 --gate 1 --gatescale 1000" +src="--src 0.01 --srcline 0.05 --srcflat 0.2 --buses 2" +fix="--levelprobe 16 --levelfix weights8" +ROWS=( + "format_only|$g --impair quant $q --images 1000 $t" + "as_budgeted|$g --impair quant,thermal,shot,prog,xtalk $r --images 1000 $t" + "lit_fitted|$g --impair quant,thermal,shot,prog,xtalk $r --images 1000 $src $fix --gatefit 1 $t" +) +SUM="$OUT/summary.md"; mkdir -p "$OUT/runs" +{ echo "| row | native s | wasm, node build s | wasm, browser module s | identical |"; echo "|---|---|---|---|---|"; } > "$SUM" +bad=0 +for row in "${ROWS[@]}"; do + name=${row%%|*}; args=${row#*|} + t0=$(date +%s.%N); "$OUT/pta_mnist" eval --data "$OUT/data" --net "$NET" --seed 1 $args > "$OUT/runs/$name.native.txt" 2>&1; rn=$? + t1=$(date +%s.%N); node "$OUT/pta_mnist_node.js" eval --data "$OUT/data" --net "$NET" --seed 1 $args > "$OUT/runs/$name.wasm.txt" 2>&1; rw=$? + t2=$(date +%s.%N); node "$HERE/run-web.mjs" "$OUT/web" "$OUT/data" eval --data @DATA --net @NET --seed 1 $args > "$OUT/runs/$name.web.txt" 2>&1; rb=$? + t3=$(date +%s.%N) + dn=$(awk "BEGIN{print $t1 - $t0}"); dw=$(awk "BEGIN{print $t2 - $t1}"); db=$(awk "BEGIN{print $t3 - $t2}") + # The browser module reads /canary.net where the others read the repo's path; + # the harness does not print the path, so the bytes must still agree. + if [ $rn -eq 0 ] && [ $rw -eq 0 ] && [ $rb -eq 0 ] && cmp -s "$OUT/runs/$name.native.txt" "$OUT/runs/$name.wasm.txt" \ + && cmp -s "$OUT/runs/$name.native.txt" "$OUT/runs/$name.web.txt"; then same=yes; else same="**no** (exit $rn/$rw/$rb)"; bad=1; fi + printf '| %s | %.2f | %.2f | %.2f | %s |\n' "$name" "$dn" "$dw" "$db" "$same" >> "$SUM" + echo "== $name: native ${dn}s, node build ${dw}s, browser module ${db}s, identical: $same" + echo "--- native output:"; head -c 1500 "$OUT/runs/$name.native.txt"; echo + [ "$same" = yes ] || { echo "--- diff, node build:"; diff "$OUT/runs/$name.native.txt" "$OUT/runs/$name.wasm.txt" | head -20; + echo "--- diff, browser module:"; diff "$OUT/runs/$name.native.txt" "$OUT/runs/$name.web.txt" | head -20; } +done +cat "$SUM" + +# rows.json: each row's arguments and the line the harness printed for it, so a +# page can offer the canary's own settings and show their published result. +python3 - "$OUT" "${ROWS[@]}" <<'PY' +import json, sys +out, rows = sys.argv[1], sys.argv[2:] +doc = [] +for r in rows: + name, args = r.split('|', 1) + line = open(f'{out}/runs/{name}.native.txt').read().strip() + doc.append(dict(name=name, args=args.split(), output=line)) +json.dump(doc, open(f'{out}/web/rows.json', 'w'), indent=2) +PY +exit $bad diff --git a/tools/pta-web/run-web.mjs b/tools/pta-web/run-web.mjs new file mode 100644 index 0000000..48cb0aa --- /dev/null +++ b/tools/pta-web/run-web.mjs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Gridworkz +// run-web.mjs -- run the browser module (web/pta_mnist.js) the way a page does: +// data copied into its in-memory filesystem, callMain with the harness's own +// arguments, stdout collected. Usage: node run-web.mjs WEB_DIR DATA_DIR ARGS... +import { readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +const [webDir, dataDir, ...args] = process.argv.slice(2); +const require = createRequire(import.meta.url); +const PtaMnist = require(join(webDir, 'pta_mnist.js')); +const out = []; +const mod = await PtaMnist({ + print: (t) => out.push(t), + printErr: (t) => out.push(t), + locateFile: (f) => join(webDir, f), +}); +mod.FS.mkdir('/data'); +for (const f of readdirSync(dataDir)) mod.FS.writeFile(`/data/${f}`, readFileSync(join(dataDir, f))); +mod.FS.writeFile('/canary.net', readFileSync(join(webDir, 'canary_seed1.net'))); +let code = 0; +try { code = mod.callMain(args.map((a) => a.replace('@DATA', '/data').replace('@NET', '/canary.net'))); } +catch (e) { if (e && typeof e.status === 'number') code = e.status; else throw e; } +process.stdout.write(out.join('\n') + (out.length ? '\n' : '')); +process.exit(code ?? 0);