File size: 39,726 Bytes
9607d4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlyMate Go — are you smarter than a fly?</title>
<meta name="description" content="Play 9×9 Go against the complete fruit-fly connectome — the board projected onto the fly's own photoreceptors, 15.1 million synapses settling on your GPU in this tab.">
<style>
:root{--bg:#0f1115;--panel:#161a21;--line:#262c36;--fg:#e6e8ec;--dim:#8b93a1;--acc:#f2c14e;--light:#e9dcc4;--dark:#a67c52;--sel:rgba(242,193,78,.55);--dot:rgba(242,193,78,.35);--bad:#ff6b6b;--ok:#7bd88f}
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
header{padding:22px 20px 8px;max-width:1080px;margin:0 auto}
h1{margin:0;font-size:26px;letter-spacing:-.01em}h1 span{color:var(--acc)}
.sub{color:var(--dim);margin:4px 0 0;font-size:14px}
main{max-width:1080px;margin:0 auto;padding:12px 20px 40px;display:grid;grid-template-columns:minmax(280px,520px) minmax(280px,1fr);gap:22px;align-items:start}
@media(max-width:760px){main{grid-template-columns:1fr}}
.goboard{width:100%;aspect-ratio:1;display:block;border-radius:6px;user-select:none;touch-action:manipulation}
.sq{display:flex;align-items:center;justify-content:center;font-size:min(7vw,44px);line-height:1;position:relative;cursor:pointer;min-height:0;overflow:hidden}
.sq.l{background:var(--light)}.sq.d{background:var(--dark)}
.sq.sel{box-shadow:inset 0 0 0 4px var(--sel)}.sq.last{box-shadow:inset 0 0 0 3px rgba(242,193,78,.7)}
.sq.dot::after{content:"";position:absolute;width:26%;height:26%;border-radius:50%;background:var(--dot)}
.sq.cap::after{content:"";position:absolute;inset:6%;border-radius:50%;border:4px solid var(--dot)}
.sq .w{color:#fff;text-shadow:0 0 2px #000,0 0 3px #000}.sq .b{color:#111}
.sq .coord{position:absolute;font-size:10px;color:rgba(0,0,0,.45);left:3px;top:2px}
.sq .coordf{position:absolute;font-size:10px;color:rgba(0,0,0,.45);right:3px;bottom:1px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:14px}
.panel h2{margin:0 0 8px;font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim)}
.status{font-size:17px;min-height:26px}
.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
button{background:#1f2530;color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:7px 12px;font:inherit;cursor:pointer}
button:hover{border-color:var(--acc)}button:disabled{opacity:.45;cursor:default}
button.primary{background:var(--acc);color:#111;border-color:var(--acc);font-weight:600}
.cands{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.7}
.cands b{color:var(--acc)}
.bar{height:6px;background:#0b0d11;border-radius:3px;overflow:hidden;margin:6px 0}.bar i{display:block;height:100%;background:var(--acc);width:0}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:var(--dim)}
.moves{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:var(--dim);max-height:110px;overflow:auto;line-height:1.6}
canvas#brain{width:100%;height:auto;display:block;background:#0b0d11;border-radius:6px}
.err{color:var(--bad)}.ok{color:var(--ok)}
footer{max-width:1080px;margin:0 auto;padding:0 20px 40px;color:var(--dim);font-size:12.5px;line-height:1.6}
#loader{margin-top:8px}#loader .bar{margin:4px 0}
label.tog{display:inline-flex;align-items:center;gap:6px;color:var(--dim);font-size:13px;cursor:pointer}
select{background:#1f2530;color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit}
</style>
</head>
<body>
<header>
  <h1>FlyMate <span>·</span> Go <span>·</span> are you smarter than a fly?</h1>
  <p class="sub">9×9 Go against the complete FlyWire fruit-fly connectome. The board is projected onto the fly's two eyes — each point lights up the ~130 photoreceptors beneath it — and 15,091,983 synapses settle on your GPU, in this tab. Nothing leaves your device. <a href="./">Chess is here.</a></p>
</header>
<main>
  <div>
    <svg class="goboard" id="board" viewBox="0 0 100 100"></svg>
    <div class="row">
      <button id="new" class="primary">New game</button>
      <button id="side">Play as White</button>
      <button id="pass">Pass</button>
      <button id="undo">Undo</button>
      <label class="tog"><input type="checkbox" id="ownov" checked> show the fly's territory estimate</label>
      <button id="duel">Watch the fly play itself</button>
    </div>
    <div class="moves" id="moves"></div>
  </div>
  <div>
    <div class="panel"><h2>Status</h2><div class="status" id="status">Your move (Black).</div><div class="mono" id="timing"></div>
      <div id="loader"><div class="mono" id="ovmsg">Waking the fly…</div><div class="bar"><i id="ovbar"></i></div><div class="mono" id="ovdetail"></div></div></div>
    <div class="panel"><h2>What the fly considered</h2><div class="cands" id="cands">—</div></div>
    <div class="panel"><h2>Brain activity (last move)</h2><canvas id="brain" width="560" height="420"></canvas><div class="mono" id="brainnote">Each dot is one neuron at its real position in the fly's brain; brightness is its activity after the board settled for 5 steps.</div></div>
    <div class="panel"><h2>The brain</h2><div class="mono" id="meta"></div></div>
  </div>
</main>
<footer>
  Same brain as the chess page, trained on 9×9 Go: KataGo g170 self-play positions (CC0) — the fly learned to agree with KataGo's most-visited move 30% of the time (random: 3.5%) and to read final territory at 75% per point (coin flip: 50%). Its sense of who is winning never trained, so it plays on instinct alone. Encoder: the 10,855 photoreceptors are cut into a 9×9 grid on each eye's sheet; each board point drives its own patch. Connectome: FlyWire FAFB v783 (Dorkenwald et al. 2024; Schlegel et al. 2024; signs from Shiu et al. 2024) — non-commercial terms apply. Idea: <a href="https://huggingface.co/mlabonne/chessfly">ChessFly</a> by Maxime Labonne. Source: <a href="https://github.com/NakliTechie/flymate">NakliTechie/flymate</a>.
</footer>

<script type="module">
// ---------- UI handles ----------
const $ = id => document.getElementById(id);
const ovmsg = $('ovmsg'), ovbar = $('ovbar'), ovdetail = $('ovdetail');
const say = (t, cls) => { $('status').innerHTML = t; $('status').className = 'status ' + (cls || ''); };
let brainError = null;
const fail = (t) => { brainError = t; ovmsg.innerHTML = `<span class="err">${t}</span>`; ovbar.style.width = '0'; throw new Error(t); };
let brainReadyResolve; const brainReady = new Promise(r => brainReadyResolve = r);

// ---------- bundle: graph files from the chess repo (shared), Go files from flymate-go ----------
const HF_GRAPH = 'https://huggingface.co/naklitechie/flymate-chess/resolve/main/';
const HF_GO = 'https://huggingface.co/naklitechie/flymate-go/resolve/main/';
const wq = new URLSearchParams(location.search).get('weights');
const SRC = f => wq === 'local' ? (GRAPH_FILES.includes(f) ? 'weights/chess/' : 'weights/go9/') + f : (GRAPH_FILES.includes(f) ? HF_GRAPH : HF_GO) + f;
const GRAPH_FILES = ['indptr.bin', 'edges.idx.bin', 'input_idx.bin', 'readout_idx.bin', 'pos.bin'];
const FILES = ['meta.json', ...GRAPH_FILES, 'edges.w.bin', 'step.bin', 'encoder.bin', 'decoder.bin', 'heads.bin'];
const EXPECT = { 'edges.idx.bin': 60.4e6, 'edges.w.bin': 30.2e6, 'decoder.bin': 42.7e6, 'step.bin': 5.5e6 };
async function loadBundle() {
  const cacheGo = await caches.open('flymate-go-v1').catch(() => null), cacheChess = await caches.open('flymate-chess-v2').catch(() => null);
  const out = {}; let done = 0; const total = FILES.reduce((a, f) => a + (EXPECT[f] || 0.3e6), 0);
  for (const f of FILES) {
    const url = SRC(f), cache = GRAPH_FILES.includes(f) ? cacheChess : cacheGo;
    let res = cache && await cache.match(url);
    if (!res) {
      const r = await fetch(url); if (!r.ok) fail(`could not fetch ${f} (${r.status})`);
      const size = +r.headers.get('content-length') || EXPECT[f] || 0.3e6;
      const reader = r.body.getReader(); const chunks = []; let got = 0;
      for (;;) { const { value, done: d } = await reader.read(); if (d) break; chunks.push(value); got += value.length;
        ovbar.style.width = (100 * (done + got) / total).toFixed(1) + '%'; ovdetail.textContent = `${f}  ${(got / 1e6).toFixed(1)} / ${(size / 1e6).toFixed(1)} MB`; }
      res = new Response(new Blob(chunks), { headers: { 'content-type': f.endsWith('.json') ? 'application/json' : 'application/octet-stream' } });
      if (cache) await cache.put(url, res.clone());
    } else ovdetail.textContent = `${f}  (cached)`;
    out[f] = f.endsWith('.json') ? await res.json() : await res.arrayBuffer();
    done += EXPECT[f] || 0.3e6; ovbar.style.width = (100 * done / total).toFixed(1) + '%';
  }
  return out;
}

// ---------- WebGPU brain ----------
const WGSL = /* wgsl */`
struct P { n: u32, b: u32, t: u32, nin: u32, nro: u32, d: u32, f: u32, nout: u32, leak: f32, roff: u32, rlen: u32, pad2: u32 }
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> indptr: array<u32>;
@group(0) @binding(2) var<storage, read> eidx: array<u32>;
@group(0) @binding(3) var<storage, read> ew: array<u32>;       // f16 pairs
@group(0) @binding(4) var<storage, read> step: array<f32>;     // [T][2][N]
@group(0) @binding(5) var<storage, read> inslot: array<u32>;   // N: input slot or 0xffffffff
@group(0) @binding(6) var<storage, read> uin: array<f32>;      // [nin][B]
@group(0) @binding(7) var<storage, read> hin: array<f32>;      // [N][B]
@group(0) @binding(8) var<storage, read_write> hout: array<f32>;
const BMAX: u32 = 16u;
fn w16(e: u32) -> f32 { let v = unpack2x16float(ew[e >> 1u]); return select(v.x, v.y, (e & 1u) == 1u); }
@compute @workgroup_size(64) fn settle(@builtin(global_invocation_id) g: vec3<u32>) {
  if (g.x >= p.rlen) { return; }
  let row = g.x + p.roff; if (row >= p.n) { return; }
  var acc: array<f32, BMAX>;
  for (var b = 0u; b < p.b; b++) { acc[b] = 0.0; }
  let e0 = indptr[row]; let e1 = indptr[row + 1u];
  for (var e = e0; e < e1; e++) {
    let w = w16(e); let base = eidx[e] * p.b;
    for (var b = 0u; b < p.b; b++) { acc[b] += w * hin[base + b]; }
  }
  let slot = inslot[row];
  let A = step[(p.t * 2u) * p.n + row]; let Bc = step[(p.t * 2u + 1u) * p.n + row];
  for (var b = 0u; b < p.b; b++) {
    var pre = acc[b]; if (slot != 0xffffffffu) { pre += uin[slot * p.b + b]; }
    let h = hin[row * p.b + b];
    hout[row * p.b + b] = (1.0 - p.leak) * h + p.leak * max(A * pre + Bc, 0.0);
  }
}
@compute @workgroup_size(64) fn clear(@builtin(global_invocation_id) g: vec3<u32>) {   // one thread per row: N·B/64 workgroups would exceed the 65535 dispatch limit at B ≥ 31
  if (g.x >= p.n) { return; }
  for (var b = 0u; b < p.b; b++) { hout[g.x * p.b + b] = 0.0; }
}
`;
const WGSL2 = /* wgsl */`
struct P { n: u32, b: u32, t: u32, nin: u32, nro: u32, d: u32, f: u32, nout: u32, leak: f32, roff: u32, rlen: u32, pad2: u32 }
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> encw: array<f32>;     // [nin][3] gain per plane   (retinotopic: point[j] fixed by the eye)
@group(0) @binding(2) var<storage, read> encb: array<f32>;     // [nin] bias, then u32 point[nin] packed after it (see encpt)
@group(0) @binding(3) var<storage, read> x: array<f32>;        // [B][3][81] planes: mover, opponent, ko
@group(0) @binding(4) var<storage, read_write> uin: array<f32>;// [nin][B]
@group(0) @binding(5) var<storage, read> decw: array<u32>;     // f16 [D][nro]
@group(0) @binding(6) var<storage, read> decb: array<f32>;
@group(0) @binding(7) var<storage, read> roidx: array<u32>;
@group(0) @binding(8) var<storage, read> h: array<f32>;        // [N][B]
@group(0) @binding(9) var<storage, read_write> z: array<f32>;  // [B][D]
@group(0) @binding(10) var<storage, read> headw: array<u32>;   // f16 [nout][D]
@group(0) @binding(11) var<storage, read> headb: array<f32>;
@group(0) @binding(12) var<storage, read_write> out: array<f32>; // [B][nout]
@group(0) @binding(13) var<storage, read_write> hroot: array<f32>; // [N] slice b=0
fn f16at(a: ptr<storage, array<u32>, read>, i: u32) -> f32 { let v = unpack2x16float((*a)[i >> 1u]); return select(v.x, v.y, (i & 1u) == 1u); }
@group(0) @binding(14) var<storage, read> encpt: array<u32>;   // [nin] board point per photoreceptor
@compute @workgroup_size(64) fn encoder(@builtin(global_invocation_id) g: vec3<u32>) {
  let id = g.x; if (id >= p.nin * p.b) { return; }
  let j = id / p.b; let b = id % p.b; let pt = encpt[j];
  var acc = encb[j];
  for (var c = 0u; c < 3u; c++) { acc += encw[j * 3u + c] * x[b * p.f + c * 81u + pt]; }
  uin[j * p.b + b] = acc;
}
var<workgroup> sh: array<f32, 256>;
@compute @workgroup_size(256) fn decoder(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) l: vec3<u32>) {
  let d = wg.x; let b = wg.y; var acc = 0.0;
  for (var k = l.x; k < p.nro; k += 256u) { acc += f16at(&decw, d * p.nro + k) * h[roidx[k] * p.b + b]; }
  sh[l.x] = acc; workgroupBarrier();
  for (var s = 128u; s > 0u; s >>= 1u) { if (l.x < s) { sh[l.x] += sh[l.x + s]; } workgroupBarrier(); }
  if (l.x == 0u) { z[b * p.d + d] = max(sh[0] + decb[d], 0.0); }
}
@compute @workgroup_size(64) fn heads(@builtin(global_invocation_id) g: vec3<u32>) {
  let id = g.x; if (id >= p.nout * p.b) { return; }
  let b = id / p.nout; let o = id % p.nout;
  var acc = headb[o];
  for (var k = 0u; k < p.d; k++) { acc += f16at(&headw, o * p.d + k) * z[b * p.d + k]; }
  out[b * p.nout + o] = acc;
}
@compute @workgroup_size(64) fn gatherRoot(@builtin(global_invocation_id) g: vec3<u32>) { if (g.x < p.n) { hroot[g.x] = h[g.x * p.b]; } }
`;

class Brain {
  constructor(device, bundle) {
    this.dev = device; const m = this.meta = bundle['meta.json'];
    const N = m.N, E = m.E, T = m.T, nin = m.n_in, nro = m.n_ro, D = m.D, F = m.planes * 81, nout = m.n_moves + m.n_bins + m.n_own;
    Object.assign(this, { N, E, T, nin, nro, D, F, nout, BMAX: 16 });
    const buf = (data, usage = GPUBufferUsage.STORAGE) => { const size = Math.ceil(data.byteLength / 4) * 4;
      const b = device.createBuffer({ size, usage: usage | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
      new Uint8Array(b.getMappedRange()).set(new Uint8Array(data)); b.unmap(); return b; };
    const empty = (bytes, usage = GPUBufferUsage.STORAGE) => device.createBuffer({ size: bytes, usage: usage | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST });
    // f16 pair arrays must be even-length in halves
    const padHalf = ab => { if ((ab.byteLength / 2) % 2 === 0) return ab; const o = new Uint8Array(ab.byteLength + 2); o.set(new Uint8Array(ab)); return o.buffer; };
    this.indptr = buf(bundle['indptr.bin']); this.eidx = buf(bundle['edges.idx.bin']); this.ew = buf(padHalf(bundle['edges.w.bin']));
    this.step = buf(bundle['step.bin']);
    const inputIdx = new Uint32Array(bundle['input_idx.bin']); const inslot = new Uint32Array(N).fill(0xffffffff); inputIdx.forEach((n, j) => inslot[n] = j);
    this.inslot = buf(inslot.buffer);
    const enc = bundle['encoder.bin']; this.encpt = buf(enc.slice(0, nin * 4)); this.encw = buf(enc.slice(nin * 4, nin * 4 + nin * 12)); this.encb = buf(enc.slice(nin * 4 + nin * 12));
    const dec = bundle['decoder.bin']; this.decw = buf(padHalf(dec.slice(0, D * nro * 2))); this.decb = buf(dec.slice(D * nro * 2));
    this.roidx = buf(bundle['readout_idx.bin']);
    // heads: [policy 82, value 64, own 81] each as W f16 [k][D] + b f32 [k] → one [nout][D] + [nout]
    const hd = new Uint8Array(bundle['heads.bin']); const hw = new Uint8Array(nout * D * 2), hb = new Uint8Array(nout * 4); let off = 0, wo = 0, bo = 0;
    for (const k of [m.n_moves, m.n_bins, m.n_own]) { hw.set(hd.subarray(off, off + k * D * 2), wo); off += k * D * 2; wo += k * D * 2; hb.set(hd.subarray(off, off + k * 4), bo); off += k * 4; bo += k * 4; }
    this.headw = buf(hw.buffer); this.headb = buf(hb.buffer);
    this.x = empty(this.BMAX * F * 4); this.uin = empty(nin * this.BMAX * 4);
    this.hA = empty(N * this.BMAX * 4); this.hB = empty(N * this.BMAX * 4);
    this.z = empty(this.BMAX * D * 4); this.out = empty(this.BMAX * nout * 4); this.hroot = empty(N * 4);
    this.read = device.createBuffer({ size: this.BMAX * nout * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
    this.readRoot = device.createBuffer({ size: N * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
    this.uni = device.createBuffer({ size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
    const mod1 = device.createShaderModule({ code: WGSL }), mod2 = device.createShaderModule({ code: WGSL2 });
    const pipe = (mod, entry) => device.createComputePipeline({ layout: 'auto', compute: { module: mod, entryPoint: entry } });
    this.pSettle = pipe(mod1, 'settle'); this.pClear = pipe(mod1, 'clear');
    this.pEnc = pipe(mod2, 'encoder'); this.pDec = pipe(mod2, 'decoder'); this.pHeads = pipe(mod2, 'heads'); this.pGather = pipe(mod2, 'gatherRoot');
    // layout:'auto' exposes only the bindings each entry point uses — build bind groups per pipeline from a full entry list
    const all1 = (hin, hout) => [[0, this.uni], [1, this.indptr], [2, this.eidx], [3, this.ew], [4, this.step], [5, this.inslot], [6, this.uin], [7, hin], [8, hout]];
    const all2 = (h) => [[0, this.uni], [1, this.encw], [2, this.encb], [3, this.x], [4, this.uin], [5, this.decw], [6, this.decb], [7, this.roidx], [8, h],
      [9, this.z], [10, this.headw], [11, this.headb], [12, this.out], [13, this.hroot], [14, this.encpt]];
    const used = { settle: [0, 1, 2, 3, 4, 5, 6, 7, 8], clear: [0, 8], encoder: [0, 1, 2, 3, 4, 14], decoder: [0, 5, 6, 7, 8, 9], heads: [0, 9, 10, 11, 12], gatherRoot: [0, 8, 13] };
    const bg = (pl, entries, keep) => device.createBindGroup({ layout: pl.getBindGroupLayout(0),
      entries: entries.filter(([i]) => keep.includes(i)).map(([binding, buffer]) => ({ binding, resource: { buffer } })) });
    this.bgAB = bg(this.pSettle, all1(this.hA, this.hB), used.settle); this.bgBA = bg(this.pSettle, all1(this.hB, this.hA), used.settle);
    this.bgClear = bg(this.pClear, all1(this.hB, this.hA), used.clear);
    const g2 = h => ({ enc: bg(this.pEnc, all2(h), used.encoder), dec: bg(this.pDec, all2(h), used.decoder), heads: bg(this.pHeads, all2(h), used.heads), gather: bg(this.pGather, all2(h), used.gatherRoot) });
    this.bg2 = { A: g2(this.hA), B: g2(this.hB) };
  }
  setUni(b, t, roff = 0, rlen = this.N) {
    const u = new ArrayBuffer(48); const dv = new DataView(u);
    [this.N, b, t, this.nin, this.nro, this.D, this.F, this.nout].forEach((v, i) => dv.setUint32(i * 4, v, true)); dv.setFloat32(32, this.meta.leak, true);
    dv.setUint32(36, roff, true); dv.setUint32(40, rlen, true);
    this.dev.queue.writeBuffer(this.uni, 0, u);
  }
  /** Rows per settle dispatch so that one GPU submission stays under targetMs (mobile GPUs kill long ones). */
  chunkRows(B) {
    if (!this.cost) return Math.ceil(this.N / 8 / 64) * 64;      // nothing measured yet: 8 submissions per step, safe on slow GPUs
    const stepMs = this.cost.a + this.cost.b * B;                 // predicted ms for one full-N settle step at batch B
    const chunks = Math.max(1, Math.ceil(stepMs / this.targetMs));
    return Math.max(1024, Math.ceil(this.N / chunks / 64) * 64);
  }
  /** Time two forwards to fit settle cost ≈ a + b·B (ms per step). */
  async calibrate(x) {
    const t = async (B) => { const t0 = performance.now(); const r = await this.forward(Array.from({ length: B }, () => x)); if (!r.logits.some(v => v !== 0)) throw new Error('GPU returned zeros (device lost?)'); return performance.now() - t0; };
    this.targetMs = this.targetMs || 250;
    await t(1);                                                 // warm shaders (chunked 8× until we know the cost)
    const t1 = await t(1);
    this.cost = { a: 0.3 * t1 / this.T, b: 0.7 * t1 / this.T };  // provisional: assume most of the B=1 time scales per column, so B=8 is chunked safely
    const t8 = await t(8);
    const b = Math.max(0.05, (t8 - t1) / 7 / this.T), a = Math.max(0.1, t1 / this.T - b);
    this.cost = { a, b };
    return { t1, t8, a, b };
  }
  /** xs: array of Float32Array(780) → { logits: Float32Array[B][nout], hroot?: Float32Array(N) } */
  async forward(xs, wantRoot = false) {
    const B = xs.length; if (B > this.BMAX) throw new Error('batch too large');
    const xb = new Float32Array(B * this.F); xs.forEach((x, i) => xb.set(x, i * this.F));
    this.dev.queue.writeBuffer(this.x, 0, xb);
    const wg = n => { const w = Math.ceil(n / 64); if (w > 65535) throw new Error(`dispatch too large: ${w} workgroups`); return w; };
    // clear hA, encoder → uin, then settle T steps ping-pong: hA→hB→hA...
    this.setUni(B, 0);
    let enc = this.dev.createCommandEncoder(); let pass = enc.beginComputePass();
    pass.setPipeline(this.pClear); pass.setBindGroup(0, this.bgClear); pass.dispatchWorkgroups(wg(this.N));
    pass.setPipeline(this.pEnc); pass.setBindGroup(0, this.bg2.A.enc); pass.dispatchWorkgroups(wg(this.nin * B));
    pass.end(); this.dev.queue.submit([enc.finish()]);
    let cur = 'A'; const rows = this.chunkRows(B);
    for (let t = 0; t < this.T; t++) {
      for (let off = 0; off < this.N; off += rows) {              // one submission per row chunk
        const len = Math.min(rows, this.N - off);
        this.setUni(B, t, off, len);
        enc = this.dev.createCommandEncoder(); pass = enc.beginComputePass();
        pass.setPipeline(this.pSettle); pass.setBindGroup(0, cur === 'A' ? this.bgAB : this.bgBA); pass.dispatchWorkgroups(wg(len));
        pass.end(); this.dev.queue.submit([enc.finish()]);
        if (off + rows < this.N) await this.dev.queue.onSubmittedWorkDone();   // let the driver breathe between chunks
      }
      cur = cur === 'A' ? 'B' : 'A';
    }
    const g = this.bg2[cur];
    enc = this.dev.createCommandEncoder(); pass = enc.beginComputePass();
    pass.setPipeline(this.pDec); pass.setBindGroup(0, g.dec); pass.dispatchWorkgroups(this.D, B);
    pass.setPipeline(this.pHeads); pass.setBindGroup(0, g.heads); pass.dispatchWorkgroups(wg(this.nout * B));
    if (wantRoot) { pass.setPipeline(this.pGather); pass.setBindGroup(0, g.gather); pass.dispatchWorkgroups(wg(this.N)); }
    pass.end();
    enc.copyBufferToBuffer(this.out, 0, this.read, 0, B * this.nout * 4);
    if (wantRoot) enc.copyBufferToBuffer(this.hroot, 0, this.readRoot, 0, this.N * 4);
    this.dev.queue.submit([enc.finish()]);
    await this.read.mapAsync(GPUMapMode.READ); const logits = new Float32Array(this.read.getMappedRange().slice(0, B * this.nout * 4)); this.read.unmap();
    let hroot = null;
    if (wantRoot) { await this.readRoot.mapAsync(GPUMapMode.READ); hroot = new Float32Array(this.readRoot.getMappedRange().slice(0)); this.readRoot.unmap(); }
    return { logits, hroot };
  }
}


// ---------- 9×9 Go rules ----------
const N = 9, PTS = 81, PASS = 81, KOMI = 7;
class Go {
  constructor() { this.board = new Int8Array(PTS); this.turn = 1; this.ko = -1; this.history = []; this.passes = 0; this.caps = { 1: 0, [-1]: 0 }; }
  clone() { const g = new Go(); g.board = this.board.slice(); g.turn = this.turn; g.ko = this.ko; g.history = this.history.slice(); g.passes = this.passes; g.caps = { ...this.caps }; return g; }
  static nbrs(p) { const x = p % N, y = (p / N) | 0, o = []; if (x > 0) o.push(p - 1); if (x < N - 1) o.push(p + 1); if (y > 0) o.push(p - N); if (y < N - 1) o.push(p + N); return o; }
  group(p, board = this.board) { const c = board[p], seen = new Set([p]), stack = [p], libs = new Set(); while (stack.length) { const q = stack.pop(); for (const n of Go.nbrs(q)) { if (board[n] === 0) libs.add(n); else if (board[n] === c && !seen.has(n)) { seen.add(n); stack.push(n); } } } return { stones: seen, libs }; }
  legal(p) { if (p === PASS) return true; if (this.board[p] !== 0 || p === this.ko) return false; const b = this.board.slice(); b[p] = this.turn; let captured = false;
    for (const n of Go.nbrs(p)) if (b[n] === -this.turn && this.group(n, b).libs.size === 0) { captured = true; break; }
    return captured || this.group(p, b).libs.size > 0; }
  play(p) { if (!this.legal(p)) return false; this.history.push({ board: this.board.slice(), ko: this.ko, caps: { ...this.caps }, passes: this.passes, p });
    if (p === PASS) { this.passes++; this.ko = -1; this.turn = -this.turn; return true; }
    this.board[p] = this.turn; let capturedStones = [];
    for (const n of Go.nbrs(p)) if (this.board[n] === -this.turn) { const g = this.group(n); if (g.libs.size === 0) { for (const s of g.stones) { this.board[s] = 0; capturedStones.push(s); } } }
    this.caps[this.turn] += capturedStones.length;
    this.ko = capturedStones.length === 1 && this.group(p).stones.size === 1 && this.group(p).libs.size === 1 ? capturedStones[0] : -1;   // simple ko
    this.passes = 0; this.turn = -this.turn; return true; }
  undo() { const h = this.history.pop(); if (!h) return; this.board = h.board; this.ko = h.ko; this.caps = h.caps; this.passes = h.passes; this.turn = -this.turn; }
  over() { return this.passes >= 2; }
  legalMask() { const m = new Array(PTS + 1).fill(false); for (let p = 0; p < PTS; p++) m[p] = this.legal(p); m[PASS] = true; return m; }
  score() {                                                     // area scoring (Tromp–Taylor), komi to White; returns black − white
    const own = new Int8Array(PTS); const seen = new Uint8Array(PTS);
    for (let p = 0; p < PTS; p++) { if (this.board[p]) { own[p] = this.board[p]; continue; } if (seen[p]) continue;
      const reg = [], stack = [p], touch = new Set(); seen[p] = 1;
      while (stack.length) { const q = stack.pop(); reg.push(q); for (const n of Go.nbrs(q)) { if (this.board[n]) touch.add(this.board[n]); else if (!seen[n]) { seen[n] = 1; stack.push(n); } } }
      const o = touch.size === 1 ? [...touch][0] : 0; for (const q of reg) own[q] = o; }
    let b = 0, w = 0; for (let p = 0; p < PTS; p++) { if (own[p] === 1) b++; else if (own[p] === -1) w++; }
    return { black: b, white: w + KOMI, diff: b - w - KOMI, own }; }
}
const planes = (g) => { const x = new Float32Array(3 * PTS); for (let p = 0; p < PTS; p++) { if (g.board[p] === g.turn) x[p] = 1; else if (g.board[p] === -g.turn) x[PTS + p] = 1; } if (g.ko >= 0) x[2 * PTS + g.ko] = 1; return x; };
const coord = p => p === PASS ? 'pass' : 'ABCDEFGHJ'[p % N] + (N - ((p / N) | 0));

// ---------- fly ----------
let brain, meta, bundle = null, game = new Go(), human = 1, busy = false, lastMove = -1, posXY = null, duel = false, lastOwn = null;
const centers = () => Float32Array.from({ length: 64 }, (_, i) => (i + 0.5) / 64);
const softmaxDot = (v, off, n, c) => { let mx = -Infinity; for (let i = 0; i < n; i++) mx = Math.max(mx, v[off + i]); let s = 0, acc = 0; for (let i = 0; i < n; i++) { const e = Math.exp(v[off + i] - mx); s += e; acc += e * c[i]; } return acc / s; };
const sig = v => 1 / (1 + Math.exp(-v));
async function think(g, wantRoot) {
  const { logits, hroot } = await brain.forward([planes(g)], wantRoot);
  const legal = g.legalMask(); const pol = new Float32Array(PTS + 1); let mx = -Infinity;
  for (let i = 0; i <= PTS; i++) { pol[i] = legal[i] ? logits[i] : -Infinity; mx = Math.max(mx, pol[i]); }
  let z = 0; for (let i = 0; i <= PTS; i++) { pol[i] = legal[i] ? Math.exp(pol[i] - mx) : 0; z += pol[i]; } for (let i = 0; i <= PTS; i++) pol[i] /= z;
  const win = softmaxDot(logits, meta.n_moves, meta.n_bins, centers());
  const own = new Float32Array(PTS); for (let p = 0; p < PTS; p++) own[p] = sig(logits[meta.n_moves + meta.n_bins + p]);   // P(point ends up the mover's)
  return { pol, win, own, hroot };
}
async function verify() {
  let ok = true, lines = [];
  for (const t of meta.tests) {
    const g = new Go(); g.board = Int8Array.from(t.stones); g.turn = 1; g.ko = t.ko.indexOf(1);
    const { logits } = await brain.forward([planes(g)]);
    const legal = [...Array(PTS)].map((_, i) => g.board[i] === 0 && i !== g.ko).concat([true]);   // the training mask (empty ∧ ¬ko); the game itself uses real rules
    let best = -Infinity, bi = -1; for (let i = 0; i <= PTS; i++) if (legal[i] && logits[i] > best) { best = logits[i]; bi = i; }
    const wp = softmaxDot(logits, meta.n_moves, meta.n_bins, centers());
    const ownErr = Math.max(...t.own_sample.map(([j, v]) => Math.abs(sig(logits[meta.n_moves + meta.n_bins + j]) - v)));
    const pass = bi === t.top5[0][0] && Math.abs(wp - t.win_prob) < 0.03 && ownErr < 0.03; ok = ok && pass;
    lines.push(`${pass ? '✓' : '✗'} top1 ${coord(bi)} (torch ${coord(t.top5[0][0])}, KataGo ${coord(t.kata_top1)})  win ${(wp * 100).toFixed(1)}% (torch ${(t.win_prob * 100).toFixed(1)}%)  ownership err ${ownErr.toFixed(3)}`);
  }
  const alive = lines.length > 0; return { ok: ok && alive, lines };
}
async function flyMove() {
  if (game.over() || busy) return; busy = true; render();
  if (!brain) { say('The fly is still waking up — it will reply once its brain is loaded.'); await brainReady; }
  if (brainError) { say(`<span class="err">The fly could not be loaded: ${brainError}</span>`); busy = false; render(); return; }
  busy = true; render(); const t0 = performance.now();
  const r = await think(game, true);
  let best = 0; for (let i = 0; i <= PTS; i++) if (r.pol[i] > r.pol[best]) best = i;
  const order = [...r.pol.keys()].sort((a, b) => r.pol[b] - r.pol[a]).slice(0, 5);
  const mover = game.turn; game.play(best); lastMove = best; lastOwn = { own: r.own, mover };
  $('timing').textContent = `1 position through the whole brain in ${(performance.now() - t0).toFixed(0)} ms · fly's win estimate ${(r.win * 100).toFixed(0)}% (its value sense is untrained — take it lightly)`;
  $('cands').innerHTML = order.map((p, i) => `${i === 0 ? '<b>' : ''}${coord(p).padEnd(5)} prior ${(r.pol[p] * 100).toFixed(0).padStart(3)}%${i === 0 ? '</b>' : ''}`).join('<br>');
  if (r.hroot) drawBrain(r.hroot);
  busy = false; render();
}
async function runDuel() {
  duel = true; $('duel').textContent = 'Stop'; game = new Go(); lastMove = -1; lastOwn = null; render();
  while (duel && !game.over()) { await flyMove(); await new Promise(r => setTimeout(r, 250)); }
  duel = false; $('duel').textContent = 'Watch the fly play itself'; render();
}
function drawBrain(h) {
  const cv = $('brain'), ctx = cv.getContext('2d'); const Wc = cv.width, Hc = cv.height;
  ctx.fillStyle = '#0b0d11'; ctx.fillRect(0, 0, Wc, Hc); if (!posXY) return;
  const n = h.length; let mx = 0; for (let i = 0; i < n; i++) mx = Math.max(mx, h[i]);
  const img = ctx.createImageData(Wc, Hc); const d = img.data;
  for (let i = 0; i < n; i++) { const px = posXY.x[i], py = posXY.y[i]; if (px < 0 || py < 0 || px >= Wc || py >= Hc) continue;
    const a = mx > 0 ? Math.min(1, Math.log1p(h[i]) / Math.log1p(mx)) : 0; const o = (py * Wc + px) * 4;
    d[o] = Math.max(d[o], 40 + 202 * a); d[o + 1] = Math.max(d[o + 1], 40 + 153 * a); d[o + 2] = Math.max(d[o + 2], 40 + 30 * a); d[o + 3] = 255; }
  ctx.putImageData(img, 0, 0);
}

// ---------- board rendering ----------
function render() {
  const svg = $('board'); const step = 100 / N, off = step / 2; let s = `<rect width="100" height="100" fill="#d9b06c" rx="2"/>`;
  for (let i = 0; i < N; i++) { const c = off + i * step; s += `<line x1="${off}" y1="${c}" x2="${100 - off}" y2="${c}" stroke="#5a3d1a" stroke-width="0.5"/><line x1="${c}" y1="${off}" x2="${c}" y2="${100 - off}" stroke="#5a3d1a" stroke-width="0.5"/>`; }
  for (const [x, y] of [[2, 2], [6, 2], [4, 4], [2, 6], [6, 6]]) s += `<circle cx="${off + x * step}" cy="${off + y * step}" r="1.1" fill="#5a3d1a"/>`;
  if ($('ownov').checked && lastOwn) for (let p = 0; p < PTS; p++) { const v = lastOwn.own[p] * (lastOwn.mover === 1 ? 1 : -1) + (lastOwn.mover === 1 ? 0 : 1);   // → P(black owns)
    const a = Math.abs(v - 0.5) * 2; if (a < 0.15) continue; const x = off + (p % N) * step, y = off + ((p / N) | 0) * step;
    s += `<rect x="${x - step * 0.22}" y="${y - step * 0.22}" width="${step * 0.44}" height="${step * 0.44}" fill="${v > 0.5 ? '#111' : '#fff'}" opacity="${(0.15 + 0.5 * a).toFixed(2)}" rx="0.6"/>`; }
  for (let p = 0; p < PTS; p++) { if (!game.board[p]) continue; const x = off + (p % N) * step, y = off + ((p / N) | 0) * step;
    s += `<circle cx="${x}" cy="${y}" r="${step * 0.46}" fill="${game.board[p] === 1 ? '#111' : '#f4f4f4'}" stroke="#222" stroke-width="0.4"/>`;
    if (p === lastMove) s += `<circle cx="${x}" cy="${y}" r="${step * 0.18}" fill="none" stroke="${game.board[p] === 1 ? '#f2c14e' : '#b8860b'}" stroke-width="0.7"/>`; }
  for (let p = 0; p < PTS; p++) { const x = off + (p % N) * step, y = off + ((p / N) | 0) * step; s += `<rect x="${x - off}" y="${y - off}" width="${step}" height="${step}" fill="transparent" data-p="${p}"/>`; }
  svg.innerHTML = s;
  svg.onclick = e => { const p = e.target.dataset.p; if (p !== undefined) onPoint(+p); };
  $('moves').textContent = game.history.map((h, i) => `${i % 2 ? 'W' : 'B'} ${coord(h.p)}`).join('  ') + (game.caps[1] || game.caps[-1] ? `   · captures B ${game.caps[1]} W ${game.caps[-1]}` : '');
  if (game.over()) { const sc = game.score(); const who = sc.diff > 0 ? 'Black' : 'White'; const you = human === 1 ? 'Black' : 'White';
    say(`Game over — ${who} wins by ${Math.abs(sc.diff)} (area scoring, komi ${KOMI}). ${duel ? '' : who === you ? 'You beat a fly.' : 'The fly wins.'}`, !duel && who === you ? 'ok' : ''); }
  else if (duel) say(`Fly (${game.turn === 1 ? 'Black' : 'White'}) is thinking…`);
  else if (busy) say('The fly is thinking…');
  else say(game.turn === human ? `Your move (${human === 1 ? 'Black' : 'White'}).` : 'Fly to move.');
  $('undo').disabled = busy || duel || game.history.length < 1; $('pass').disabled = busy || duel || game.over() || game.turn !== human;
  $('side').textContent = human === 1 ? 'Play as White' : 'Play as Black';
}
function onPoint(p) { if (busy || duel || game.over() || game.turn !== human) return; if (!game.play(p)) return; lastMove = p; render(); setTimeout(flyMove, 30); }
$('pass').onclick = () => { if (busy || duel || game.over() || game.turn !== human) return; game.play(PASS); lastMove = -1; render(); if (!game.over()) setTimeout(flyMove, 30); };
$('new').onclick = () => { if (busy && !duel) return; duel = false; $('duel').textContent = 'Watch the fly play itself'; game = new Go(); lastMove = -1; lastOwn = null; $('cands').textContent = '—'; render(); if (human === -1) setTimeout(flyMove, 30); };
$('side').onclick = () => { if (busy) return; human = -human; $('new').onclick(); };
$('undo').onclick = () => { if (busy || duel) return; game.undo(); if (game.turn !== human) game.undo(); lastMove = -1; render(); };
$('duel').onclick = () => { if (duel) { duel = false; return; } if (busy) return; runDuel(); };
$('ownov').onchange = render;

// ---------- boot ----------
render();
(async () => {
  if (!navigator.gpu) fail('This browser has no WebGPU — the fly needs it. Use Chrome, Edge, or Safari 26+ on a machine with a GPU.');
  const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); if (!adapter) fail('No WebGPU adapter found.');
  const lim = adapter.limits; const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: Math.min(lim.maxStorageBufferBindingSize, 256 << 20), maxBufferSize: Math.min(lim.maxBufferSize, 256 << 20) } }).catch(e => fail('WebGPU device: ' + e.message));
  let booting = true, losses = 0;
  const onLost = async (i) => { losses++; say(`<span class="err">GPU reset (${i.reason || 'device lost'}) — re-wiring the brain…</span>`);
    try { const ad = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); const dv = await ad.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: Math.min(ad.limits.maxStorageBufferBindingSize, 256 << 20), maxBufferSize: Math.min(ad.limits.maxBufferSize, 256 << 20) } }); dv.lost.then(onLost);
      const old = brain; brain = new Brain(dv, bundle); brain.cost = old && old.cost; brain.targetMs = Math.max(40, ((old && old.targetMs) || 250) / 2); if (!booting) { busy = false; say('The brain is back. Your move.'); } }
    catch (e) { say(`<span class="err">Could not recover the GPU: ${e.message}. Reload the page.</span>`); } };
  device.lost.then(onLost);
  ovmsg.textContent = 'Downloading the fly\'s brain (140 MB; 79 MB if you already played chess here; cached after the first visit) — you can start placing stones meanwhile.';
  bundle = await loadBundle(); meta = bundle['meta.json'];
  ovmsg.textContent = 'Wiring 15.1 million synapses into your GPU…'; ovdetail.textContent = ''; await new Promise(r => setTimeout(r, 20));
  try { brain = new Brain(device, bundle); } catch (e) { fail('Brain setup: ' + e.message); }
  { const u16 = new Uint16Array(bundle['pos.bin']); const f16 = h => { const s = (h & 0x8000) ? -1 : 1, e = (h >> 10) & 0x1f, f = h & 0x3ff; return e === 0 ? s * f * 2 ** -24 : e === 31 ? NaN : s * (1 + f / 1024) * 2 ** (e - 15); };
    const n = u16.length / 2; const xy = new Float32Array(u16.length); for (let i = 0; i < u16.length; i++) xy[i] = f16(u16[i]);
    let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity; for (let i = 0; i < n; i++) { minx = Math.min(minx, xy[2 * i]); maxx = Math.max(maxx, xy[2 * i]); miny = Math.min(miny, xy[2 * i + 1]); maxy = Math.max(maxy, xy[2 * i + 1]); }
    const cv = $('brain'); const sc = Math.min((cv.width - 8) / (maxx - minx), (cv.height - 8) / (maxy - miny)); posXY = { x: new Int16Array(n), y: new Int16Array(n) };
    for (let i = 0; i < n; i++) { posXY.x[i] = 4 + (xy[2 * i] - minx) * sc; posXY.y[i] = 4 + (xy[2 * i + 1] - miny) * sc; } }
  let cal, v, dt, attempt = 0;
  for (;;) { attempt++; const lossesBefore = losses;
    try { ovmsg.textContent = 'Measuring your GPU…'; cal = await brain.calibrate(planes(new Go()));
      ovmsg.textContent = 'Checking the fly against the reference implementation…'; const t0 = performance.now(); v = await verify(); dt = (performance.now() - t0) / meta.tests.length;
      if (v.ok || losses === lossesBefore) break; }
    catch (e) { if (attempt >= 3) fail('GPU setup failed: ' + e.message); await new Promise(r => setTimeout(r, 500)); continue; }
    if (attempt >= 3) break; await new Promise(r => setTimeout(r, 500)); }
  booting = false;
  $('meta').innerHTML = `${meta.N.toLocaleString()} neurons · ${meta.E.toLocaleString()} synapses · ${meta.T} settle steps · leak ${meta.leak}<br>retinotopic encoder: 3 planes × 81 points → ${meta.n_in} photoreceptors · readout ${meta.n_ro.toLocaleString()} neurons → ${meta.n_moves} moves + ${meta.n_bins} value bins + ${meta.n_own} ownership<br>checkpoint: ${meta.checkpoint}<br>` +
    `<span class="${v.ok ? 'ok' : 'err'}">${v.ok ? 'verified' : 'MISMATCH'} against torch on ${meta.tests.length} positions</span> · ${dt.toFixed(0)} ms per forward pass · settle ≈ ${cal.a.toFixed(0)} + ${cal.b.toFixed(1)}·B ms per step<br>` + v.lines.join('<br>');
  if (!v.ok) { brain = null; fail('The GPU brain does not match the reference implementation. ' + v.lines.join(' | ')); }
  $('loader').style.display = 'none'; render();
  window.flymate = { brain: () => brain, game: () => game, Go, planes };
})().catch(e => { console.error(e); if (!brainError) { brainError = e.message; ovmsg.innerHTML = `<span class="err">${e.message}</span>`; } }).finally(() => brainReadyResolve());
</script>
</body>
</html>