1/**
2 * Is the browser computing the same thing as the terminal?
3 *
4 * Runs one identical spec in both — same model source, parameters, lmax, seed and
5 * step count — and compares the final spectral state. The pipeline is
6 * deterministic given that spec (seeded PRNG, then fixed arithmetic), so the two
7 * should agree to fp32 round-off. They will not agree bit for bit: GPUs differ in
8 * fused-multiply-add and other latitude fp32 allows. They should agree to far
9 * better than any real difference in what is being computed.
10 *
11 * Both sides build their spec through the same parseArgs, so neither can quietly
12 * use a different default.
13 *
14 * node scripts/compare-env.mjs [--lmax 31] [--steps 200] [--preset schnak-spots]
15 *
16 * Requires `npm run build` first (it serves dist/), and desktop WebGPU for the
17 * terminal side.
18 */
19import { createServer } from 'node:http';
20import { readFile, unlink } from 'node:fs/promises';
21import { readFileSync } from 'node:fs';
22import { extname, join } from 'node:path';
23import { spawnSync } from 'node:child_process';
24import { tmpdir } from 'node:os';
25import puppeteer from 'puppeteer-core';
27// ---- spec, defaulted small enough to be quick in a browser ----------------
28const argv = process.argv.slice(2);
29const flag = (name, dflt) => {
30 const i = argv.indexOf(`--${name}`);
31 if (i >= 0 && argv[i + 1] !== undefined) return argv[i + 1];
32 const eq = argv.find((a) => a.startsWith(`--${name}=`));
33 return eq ? eq.slice(name.length + 3) : dflt;
34};
35const lmax = flag('lmax', '31');
36const steps = flag('steps', '200');
37const preset = flag('preset', 'schnak-spots');
38const seed = flag('seed', '1');
39const tolerance = Number(flag('tolerance', '2e-3'));
41const statePath = join(tmpdir(), `turing-sphere-desktop-${process.pid}.json`);
43// ---- desktop -------------------------------------------------------------
44console.log(`comparing environments — preset ${preset}, lmax ${lmax}, ${steps} steps, seed ${seed}\n`);
45console.log('desktop (Dawn):');
46const bench = spawnSync(
47 'npx',
48 [
49 'vite-node', 'scripts/bench.ts',
50 '--preset', preset, '--lmax', lmax, '--seed', seed,
51 '--steps', steps, '--warmup', '10',
52 '--dump-state', statePath,
53 ],
54 { encoding: 'utf8' },
55);
56if (bench.status !== 0) {
57 console.error(bench.stdout ?? '');
58 console.error(bench.stderr ?? '');
59 console.error('compare-env: the desktop run failed');
60 process.exit(1);
61}
62const desktop = JSON.parse(readFileSync(statePath, 'utf8'));
63console.log(` ${fmt(desktop.digest)}`);
64console.log(` adapter: ${desktop.digest.adapter}`);
66// ---- browser -------------------------------------------------------------
67const DIST = new URL('../dist/', import.meta.url).pathname;
68const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
69const server = createServer(async (req, res) => {
70 try {
71 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
72 const data = await readFile(join(DIST, path));
73 res.writeHead(200, {
74 'content-type': MIME[extname(path)] ?? 'application/octet-stream',
75 });
76 res.end(data);
77 } catch {
78 res.writeHead(404);
79 res.end('not found');
80 }
81});
82await new Promise((r) => server.listen(0, '127.0.0.1', r));
83const port = server.address().port;
85const flagSets = [
86 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
87 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
88 '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
89];
91let browserState = null;
92let lastError = '';
93for (const args of flagSets) {
94 let browser;
95 try {
96 browser = await puppeteer.launch({
97 executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
98 args,
99 });
100 const page = await browser.newPage();
101 page.on('pageerror', (e) => (lastError = e.message));
102 const url =
103 `http://127.0.0.1:${port}/test.html?state=1&preset=${preset}` +
104 `&lmax=${lmax}&seed=${seed}&steps=${steps}`;
105 await page.goto(url, { waitUntil: 'load' });
106 await page.waitForFunction(() => window.__STATE__ !== undefined, { timeout: 180000 });
107 browserState = await page.evaluate(() => window.__STATE__);
108 await browser.close();
109 break;
110 } catch (e) {
111 lastError = e.message ?? String(e);
112 await browser?.close();
113 }
114}
115server.close();
116await unlink(statePath).catch(() => {});
118if (!browserState) {
119 console.error(`\ncompare-env: the browser run failed: ${lastError}`);
120 process.exit(1);
121}
123console.log('\nbrowser:');
124console.log(` ${fmt(browserState.digest)}`);
125console.log(` adapter: ${browserState.digest.adapter}`);
127// ---- compare -------------------------------------------------------------
128const a = desktop.state;
129const b = browserState.state;
130if (a.length !== b.length) {
131 console.error(`\nFAIL different state sizes: ${a.length} vs ${b.length}`);
132 process.exit(1);
133}
134let num = 0;
135let den = 0;
136let worst = 0;
137for (let i = 0; i < a.length; i++) {
138 const d = a[i] - b[i];
139 num += d * d;
140 den += b[i] * b[i];
141 worst = Math.max(worst, Math.abs(d));
142}
143const rel = Math.sqrt(num / Math.max(den, 1e-300));
145console.log('\ndifference:');
146console.log(` relative L2 ${rel.toExponential(3)}`);
147console.log(` worst element ${worst.toExponential(3)}`);
148if (desktop.digest.fourier !== browserState.digest.fourier) {
149 console.log(
150 ` NOTE different Fourier stage (${desktop.digest.fourier} vs ` +
151 `${browserState.digest.fourier}) — those are different algorithms, so they ` +
152 `round differently. That alone can explain a difference in the values.`,
153 );
154}
156const ok = rel < tolerance;
157console.log(
158 `\n${ok ? 'PASS' : 'FAIL'} the two environments compute the same thing ` +
159 `(relative L2 ${rel.toExponential(2)}, tolerance ${tolerance.toExponential(1)})`,
160);
161process.exit(ok ? 0 : 1);
163function fmt(d) {
164 const g = (v) => v.toPrecision(9);
165 return (
166 `n=${d.n} min=${g(d.min)} max=${g(d.max)} mean=${g(d.mean)} rms=${g(d.rms)} ` +
167 `fourier=${d.fourier}`
168 );
169}