Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes · 4 behind

The brain becomes engine: the policy network moves behind the door, the game keeps what it observes #114

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/106-the-brain-becomes-engine-the-policy-netw → main 1 commit fork

  • Merged. torque/106-the-brain-becomes-engine-the-policy-netw was merged into main as 4319155.

Review

Sign in to review this change.
21 files changed +318 −146
README.md +12 −2
@@ -133,7 +133,12 @@ subtends — and the panel says which of the two you are looking at.
133133 yaw, a size and a vertical squash — so one uploaded mesh draws at any size,
134134 which is how the range draws a plate of any half-width from the one cube it
135135 handed in — and it knows no game, no world and no model, with a canvas and a
136 GL context named in it only as types).
136 GL context named in it only as types) and `net` (a policy network: a `Shape` a
137 game picks, a forward pass over an observation array the game fills, one
138 categorical head per choice it may make, and int8 packing for the weights a
139 page downloads — the arithmetic only, with nothing about aiming, weapons or
140 sides in it; what an observation holds is `observe` and what a head means is
141 `action`, and both stay with the game).
137142 Three rules keep it a package, and a test holds each: the engine imports
138143 nothing outside itself, it needs nothing from a browser to load, and
139144 everything outside it comes in through one door.
@@ -200,7 +205,7 @@ subtends — and the panel says which of the two you are looking at.
200205 `DE_FORGE` — one value of `Level` and the only one that ships),
201206 `physics`, `weapons`, `combat`, `match` (the rules),
202207 `world` (the simulation),
203 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
208 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
204209 `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and
205210 tested: the WebGL2 is the engine's now, and this game has no renderer of its
206211 own. The level is handed in rather than imported: `createWorld(level, …)`
@@ -487,6 +492,11 @@ now a policy learned with PPO.
487492 structured action into independent heads instead of one 3402-way choice is the
488493 trick PufferLib calls emulation, and it is why one plain PPO loop drives all
489494 of it.
495 - **What it thinks with** — `src/engine/net.ts`, which is behind the engine's
496 door because it names nothing about this game: two tanh layers, one logit
497 vector cut into the heads above, a value the trainer reads and the page throws
498 away, and int8 packing for the weights. The game brings the observation, the
499 heads and the trained bytes; the engine brings the arithmetic.
490500 - **Where it learned** — `src/rl/arena.ts`, a duel on de_forge with the real
491501 movement, the real bullets and the real damage, and no round, economy or bomb.
492502 It steps about 13,000 ticks a second on one core, allocation-free; the policy
scripts/train.ts +4 −5
@@ -15,12 +15,11 @@ import { writeFileSync } from "node:fs";
1515 import { join } from "node:path";
1616 import { HEADS, decode } from "../src/client/action.ts";
1717 import { botThink, makeBrain } from "../src/client/bots.ts";
18 import { rng } from "../src/engine/index.ts";
19 import { OBS_SIZE } from "../src/client/observe.ts";
2018 import {
21 copyPolicy, createPolicy, forward, greedy, newActivations, pack, sample, unpack,
19 copyPolicy, createPolicy, forward, greedy, newActivations, pack, rng, sample, unpack,
2220 type Policy, type Shape,
23 } from "../src/client/policy.ts";
21 } from "../src/engine/index.ts";
22 import { OBS_SIZE } from "../src/client/observe.ts";
2423 import {
2524 createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, reset, scriptedFoe, SKIP,
2625 step, stepInput, stillFoe, type Arena,
@@ -277,7 +276,7 @@ function weightsModule(packed: ReturnType<typeof pack>, card: Card): string {
277276 * that produced them measured, and it is what the page and the terminal show.
278277 */
279278
280 import type { Packed } from "./policy.ts";
279 import type { Packed } from "../engine/index.ts";
281280
282281 export type Card = {
283282 trainedAt: string;
src/client/action.test.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * What a sample from the policy means, which is this game's and not the
3 * engine's: the engine hands back one integer per head and knows nothing about
4 * what any of them says. These tests moved out of `policy.test.ts` when the
5 * network itself went behind the door in #106 — the arithmetic went with it as
6 * `src/engine/net.test.ts`, and the bins stayed here, where the bins are.
7 */
8
9 import { expect, test } from "bun:test";
10 import { AXIS_BINS, decode, PITCH_BINS, toInput, YAW_BINS } from "./action.ts";
11
12 test("actions decode to the bins they name, and to a playable input", () => {
13 const actions = new Int32Array([0, PITCH_BINS.length - 1, 0, 2, 1, 2]);
14 const intent = decode(actions);
15 expect(intent.yawRate).toBe(YAW_BINS[0]);
16 expect(intent.pitchRate).toBe(PITCH_BINS[PITCH_BINS.length - 1]);
17 expect(intent.forward).toBe(AXIS_BINS[0]);
18 expect(intent.side).toBe(AXIS_BINS[2]);
19 expect(intent.fire).toBe(true);
20 expect(intent.duck).toBe(true);
21 expect(intent.walk).toBe(false);
22
23 const input = toInput(intent, true, "ak47");
24 expect(input.fire).toBe(true);
25 expect(input.reload).toBe(true);
26 expect(input.duck).toBe(true);
27 expect(input.jump).toBe(false);
28 expect(input.switchTo).toBe("ak47");
29 // Turning is not an Input: the caller moves the view, the way a mouse does.
30 expect(Object.keys(input)).not.toContain("yawRate");
31 });
32
33 test("the middle bin of every axis is standing still", () => {
34 const middle = new Int32Array([
35 YAW_BINS.indexOf(0), PITCH_BINS.indexOf(0), AXIS_BINS.indexOf(0), AXIS_BINS.indexOf(0), 0, 0,
36 ]);
37 const intent = decode(middle);
38 expect(intent).toEqual({
39 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
40 });
41 });
src/client/bots.ts +4 −4
@@ -12,12 +12,12 @@
1212 */
1313
1414 import { decode, HEADS, STILL, type Intent } from "./action.ts";
15 import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/index.ts";
15 import {
16 angleDelta, clamp, dist, forward, newActivations, rng, sample, unpack,
17 type Activations, type Policy, type Vec3,
18 } from "../engine/index.ts";
1619 import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts";
1720 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
18 import {
19 forward, newActivations, sample, unpack, type Activations, type Policy,
20 } from "./policy.ts";
2121 import { CARD, PACKED } from "./weights.ts";
2222 import { WEAPONS, type WeaponId } from "./weapons.ts";
2323 import type { Input, Player, World } from "./world.ts";
src/client/bundle.test.ts +15 −1
@@ -19,7 +19,7 @@
1919 */
2020
2121 import { afterAll, expect, test } from "bun:test";
22 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22 import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2323 import { tmpdir } from "node:os";
2424 import { join } from "node:path";
2525 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
@@ -174,6 +174,20 @@ test("a bundle that is missing or doubled is a sentence saying what to do about
174174 }
175175 });
176176
177 test("the shooter bundles the engine's policy network, because its bots run it", () => {
178 // The other end of `src/range/bundle.test.ts`'s claim, read as names the
179 // minifier cannot rename: the activation the forward pass runs through, and
180 // the `atob` the shipped weights are decoded with. `src/client/bots.ts`
181 // unpacks once and runs it on the frame a bot thinks.
182 const code = readFileSync(bundlePath("app"), "utf8");
183 for (const name of ["Math.tanh", "atob"]) expect(code).toContain(name);
184 // And `btoa` is not here, which is the tree shaking working in the other
185 // direction: quantising a policy is `pack`, and `pack` only ever runs in
186 // `scripts/train.ts`. A page that shipped the quantiser would be shipping a
187 // trainer it cannot use.
188 expect(code).not.toContain("btoa");
189 });
190
177191 test("the stub page leaves nothing of itself behind", () => {
178192 // Every boot above undid its install, so no other test inherits a document —
179193 // nor a `fetch` that answers every URL with a leaderboard.
src/client/weights.test.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * The shipped weights, held to the bytes they are. `weights.ts` is generated by
3 * `scripts/train.ts` and the engine's `unpack` turns it back into floats; this
4 * file is the pin that says the floats did not move.
5 *
6 * The digest is sha256 over every tensor's bytes in `tensorsOf` order, which is
7 * the order packing walks them in. It is here because #106 moved the network
8 * behind the door: a refactor of the arithmetic that changed one dequantised
9 * weight by one ulp would otherwise be invisible until a bot missed a shot, and
10 * the seeded duel in `src/rl/learned.test.ts` would be the first to notice.
11 * Retraining changes both the bytes and this digest, in one commit, on purpose.
12 */
13
14 import { expect, test } from "bun:test";
15 import { tensorsOf, unpack } from "../engine/index.ts";
16 import { HEADS } from "./action.ts";
17 import { OBS_SIZE } from "./observe.ts";
18 import { PACKED } from "./weights.ts";
19
20 /** What `unpack(PACKED)` has dequantised to since the weights were trained. */
21 const DIGEST = "4d908fcfd1198e8326d2711c95761f08770e681ee817b42af95f38f2c3b502d1";
22
23 test("the shipped weights unpack to the shape the game asks the policy for", () => {
24 const p = unpack(PACKED);
25 expect(p.shape.heads).toEqual(HEADS);
26 expect(p.shape.obs).toBe(OBS_SIZE);
27 expect(p.shape.obs).toBe(32);
28 expect(p.w1.length).toBe(p.shape.hidden * p.shape.obs);
29 for (const v of p.w1) expect(Number.isFinite(v)).toBe(true);
30 });
31
32 test("the shipped weights unpack to exactly the floats they did before", () => {
33 const hasher = new Bun.CryptoHasher("sha256");
34 for (const t of tensorsOf(unpack(PACKED)))
35 hasher.update(new Uint8Array(t.buffer, t.byteOffset, t.byteLength));
36 expect(hasher.digest("hex")).toBe(DIGEST);
37 });
src/client/weights.ts +1 −1
@@ -6,7 +6,7 @@
66 * that produced them measured, and it is what the page and the terminal show.
77 */
88
9 import type { Packed } from "./policy.ts";
9 import type { Packed } from "../engine/index.ts";
1010
1111 export type Card = {
1212 trainedAt: string;
src/engine/GUIDE.md +47
@@ -394,6 +394,52 @@ half lives beside your entry: `src/client/stage.test-helper.ts` and
394394 It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts`
395395 reads both committed bundles to say so.
396396
397 ### net
398
399 A small policy network: an observation in, one choice per categorical head out.
400 It is the one group behind the door that a game may reasonably never open — the
401 shooter runs it every tick to fight with, and the range ships not a byte of it —
402 and it is here because nothing in it is about shooting. The example does not use
403 it either, which is why this section has no code block: read
404 `src/client/bots.ts` for a policy driving a game and `src/rl/ppo.ts` for one
405 being trained.
406
407 **What a game brings first is a `Shape`:** how many numbers its observation has,
408 how wide the hidden layer is, and how many choices each of its heads offers —
409 none more than 32. That is the whole of what the engine is told. `createPolicy`
410 makes a `Policy` of that shape from a seeded generator, initialised so the first
411 draw is nearly uniform and the first value is nearly zero; `zeroPolicy` is the
412 same shape with every number zero, and `copyPolicy` is a deep copy of one.
413 `paramCount` is how many numbers a shape comes to, `logitCount` is how many
414 logits its heads add up to, and `tensorsOf` is every parameter array in one
415 fixed order — the order packing writes and an optimiser walks, so a trainer
416 never has to name `Policy.w1` or `Policy.bv` itself.
417
418 **Then a game brings the observation, every tick.** It owns the `Float32Array`
419 and what is in it: `forward` reads one observation out of it, from an offset, so
420 a rollout can keep a thousand of them end to end in one buffer. What comes back
421 is `Activations` — the two hidden layers, the flat logit vector and the value —
422 made once by `newActivations` and rewritten in place ever after, because
423 inference that allocates is inference you feel. `sample` draws one choice per
424 head into an `Int32Array` the game supplies and returns the summed
425 log-probability of what it drew; `greedy` takes the largest of each head instead,
426 which is what a game runs when it wants no jitter. Under both, `softmax`
427 normalises one head's slice in place and hands back its log-sum-exp, and
428 `headOffset` says where a head starts in the flat vector.
429
430 **And a game brings weights it trained.** `pack` quantises a policy to one byte
431 per number with a scale per tensor — about eight kilobytes instead of thirty —
432 and `Packed` is what that is: the shape, the scales and the bytes as base64,
433 which is a value a generated module can hold. `unpack` turns it back into floats
434 once, when the page loads, and everything after that runs on the floats.
435 `src/client/weights.ts` is one of these, written by `scripts/train.ts` and
436 pinned byte for byte by `src/client/weights.test.ts`.
437
438 What the engine never learns is what any of those numbers mean. How many there
439 are and what each one is worth is `src/client/observe.ts`; which head is a turn
440 and which is a trigger is `src/client/action.ts`; both are the shooter's, and a
441 second game that wanted an agent would write its own two and share this.
442
397443 ## The seam, named as pairs
398444
399445 Two games ship here, and every place they differ is a place the engine chose to
@@ -414,6 +460,7 @@ own tuning, and nothing else in this table at all.
414460 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
415461 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
416462 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
463 | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net |
417464
418465 ## What the engine deliberately does not know
419466
src/engine/boundary.test.ts +27 −10
@@ -231,29 +231,46 @@ test("the example imports the door and nothing else, which is what it is for", (
231231 /**
232232 * The surface. This list is the engine's public API written down: a name that
233233 * arrives, leaves or is renamed fails here and is read in the pull. Sorted the
234 * way `Array.prototype.sort` sorts, so capitals come first.
234 * way `Array.prototype.sort` sorts, so capitals come first — which is why a
235 * group that lands behind the door arrives scattered through this list rather
236 * than as the run of lines it is in `index.ts`. So a group is also written out
237 * as a group in the comment above its names, and #106's is the first:
238 *
239 * net — `Shape`, `Policy`, `Activations`, `Packed`, `logitCount`,
240 * `paramCount`, `tensorsOf`, `zeroPolicy`, `createPolicy`, `copyPolicy`,
241 * `newActivations`, `forward`, `headOffset`, `softmax`, `sample`, `greedy`,
242 * `pack`, `unpack`.
235243 *
236244 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
237245 * copy of the surface, read twice, rather than two that drift.
238246 */
239247 export const SURFACE = [
248 "Activations",
240249 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor",
241250 "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow",
242251 "Frames",
243252 "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions",
244 "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "PageOptions", "Paint", "Renderer",
253 "Mat4", "Mesh", "MouseEventish", "Options", "Overlay",
254 "Packed", "PageOptions", "Paint", "Policy", "Renderer",
245255 "RendererAssets",
246 "Scene", "SceneCapacity", "Shader", "SoundDevice", "Stick", "Surface", "Tone",
256 "Scene", "SceneCapacity", "Shader", "Shape", "SoundDevice", "Stick", "Surface", "Tone",
247257 "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors",
248 "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls",
249 "createLoop", "createRenderer", "createScene", "createSoundDevice", "cross", "deg", "dist",
258 "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf",
259 "copyPolicy", "createControls",
260 "createLoop", "createPolicy", "createRenderer", "createScene", "createSoundDevice", "cross",
261 "deg", "dist",
250262 "dist2d", "dot", "fakePage", "fbm",
251 "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid",
252 "modelMatrix", "mul", "multiply", "nextInstance", "noise", "normalize", "overlay", "paintAtlas",
263 "findPath", "finish",
264 "forward", "greedy", "hash", "headOffset", "inside", "length", "length2d", "lerp",
265 "logitCount", "mat4", "mergeSolid",
266 "modelMatrix", "mul", "multiply",
267 "newActivations", "nextInstance", "noise", "normalize", "overlay",
268 "pack", "paintAtlas", "paramCount",
253269 "perspective", "pushLine",
254 "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf", "scale", "segmentLength", "sub",
255 "traceWorld",
256 "v3", "viewMatrix", "visible", "worldOf",
270 "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf",
271 "sample", "scale", "segmentLength", "softmax", "sub", "tensorsOf",
272 "traceWorld", "unpack",
273 "v3", "viewMatrix", "visible", "worldOf", "zeroPolicy",
257274 ];
258275
259276 test("the door offers exactly the names written down here", () => {
src/engine/guide.test.ts +3 −3
@@ -61,9 +61,9 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6161 * this file ignores without needing to be told about it.
6262 */
6363 const NOT_THE_ENGINES = new Set([
64 "AudioContext", "Float32Array", "TEXTURE_2D_ARRAY", "addEventListener", "cancelAnimationFrame",
65 "de_forge", "pointerLockElement", "removeEventListener", "requestAnimationFrame",
66 "uniformMatrix4fv",
64 "AudioContext", "Float32Array", "Int32Array", "TEXTURE_2D_ARRAY", "addEventListener",
65 "cancelAnimationFrame", "de_forge", "pointerLockElement", "removeEventListener",
66 "requestAnimationFrame", "uniformMatrix4fv",
6767 ]);
6868
6969 const guide = (): string => readFileSync(GUIDE, "utf8");
src/engine/index.ts +7
@@ -76,3 +76,10 @@ export { fakePage } from "./page.ts";
7676 export type {
7777 FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions,
7878 } from "./page.ts";
79
80 /** A policy network: an observation in, one choice per categorical head out. */
81 export {
82 copyPolicy, createPolicy, forward, greedy, headOffset, logitCount, newActivations, pack,
83 paramCount, sample, softmax, tensorsOf, unpack, zeroPolicy,
84 } from "./net.ts";
85 export type { Activations, Packed, Policy, Shape } from "./net.ts";
src/client/policy.test.ts → src/engine/net.test.ts renamed +17 −43
@@ -1,14 +1,27 @@
1 /**
2 * The network, checked as a network. Every test here is arithmetic on a policy
3 * — what a fresh one believes, how many numbers it holds, which choice a set of
4 * logits draws and how much of a weight survives one byte — and not one of them
5 * names an observation, a bin or a weapon, because this module knows none.
6 *
7 * The heads below are a shape, not a game's: what the shooter's six mean is
8 * `src/client/action.ts`, and the tests that hold those against the weights it
9 * ships are `src/client/action.test.ts` and `src/client/weights.test.ts`, which
10 * is where they stayed when this module came through the door.
11 */
12
113 import { expect, test } from "bun:test";
2 import { AXIS_BINS, decode, HEADS, PITCH_BINS, toInput, YAW_BINS } from "./action.ts";
3 import { rng } from "../engine/index.ts";
14 import { rng } from "./math.ts";
415 import {
516 createPolicy, forward, greedy, headOffset, logitCount, newActivations, pack, paramCount,
617 sample, softmax, unpack, type Shape,
7 } from "./policy.ts";
8 import { PACKED } from "./weights.ts";
18 } from "./net.ts";
919
1020 const SHAPE: Shape = { obs: 4, hidden: 5, heads: [3, 2] };
1121
22 /** Six heads of a few choices each: the size of shape a real game trains. */
23 const HEADS = [9, 7, 3, 3, 2, 3];
24
1225 test("a fresh policy is nearly uniform, so nothing is believed before it is learned", () => {
1326 const p = createPolicy(SHAPE, rng(11));
1427 const a = forward(p, new Float32Array([0.5, -0.2, 1, 0]), newActivations(SHAPE));
@@ -74,42 +87,3 @@ test("int8 packing survives the round trip closely enough to ship", () => {
7487 expect(worst).toBeLessThanOrEqual(scale / 127 / 2 + 1e-6);
7588 expect(back.shape).toEqual(p.shape);
7689 });
77
78 test("the shipped weights unpack to the shape the game asks the policy for", () => {
79 const p = unpack(PACKED);
80 expect(p.shape.heads).toEqual(HEADS);
81 expect(p.shape.obs).toBe(32);
82 expect(p.w1.length).toBe(p.shape.hidden * p.shape.obs);
83 for (const v of p.w1) expect(Number.isFinite(v)).toBe(true);
84 });
85
86 test("actions decode to the bins they name, and to a playable input", () => {
87 const actions = new Int32Array([0, PITCH_BINS.length - 1, 0, 2, 1, 2]);
88 const intent = decode(actions);
89 expect(intent.yawRate).toBe(YAW_BINS[0]);
90 expect(intent.pitchRate).toBe(PITCH_BINS[PITCH_BINS.length - 1]);
91 expect(intent.forward).toBe(AXIS_BINS[0]);
92 expect(intent.side).toBe(AXIS_BINS[2]);
93 expect(intent.fire).toBe(true);
94 expect(intent.duck).toBe(true);
95 expect(intent.walk).toBe(false);
96
97 const input = toInput(intent, true, "ak47");
98 expect(input.fire).toBe(true);
99 expect(input.reload).toBe(true);
100 expect(input.duck).toBe(true);
101 expect(input.jump).toBe(false);
102 expect(input.switchTo).toBe("ak47");
103 // Turning is not an Input: the caller moves the view, the way a mouse does.
104 expect(Object.keys(input)).not.toContain("yawRate");
105 });
106
107 test("the middle bin of every axis is standing still", () => {
108 const middle = new Int32Array([
109 YAW_BINS.indexOf(0), PITCH_BINS.indexOf(0), AXIS_BINS.indexOf(0), AXIS_BINS.indexOf(0), 0, 0,
110 ]);
111 const intent = decode(middle);
112 expect(intent).toEqual({
113 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
114 });
115 });
src/client/policy.ts → src/engine/net.ts renamed +12 −8
@@ -1,17 +1,21 @@
11 /**
2 * The policy a bot thinks with: a small multi-layer perceptron, written out by
3 * hand so the same code trains on the server and runs in the page.
2 * A small policy network: one observation in, one choice per head out, written
3 * out by hand so the same code trains on a server and runs in a page.
44 *
55 * obs -> tanh(W1) -> tanh(W2) -> logits (several categorical heads)
66 * \-> value (one number, training only)
77 *
88 * Several heads instead of one big one is PufferLib's "emulation" idea: a
9 * structured action (turn this much, walk that way, fire or not) is flattened
10 * into a list of independent categoricals, so one plain PPO loop drives it and
11 * the action space never explodes into the product of its parts.
9 * structured action — a handful of choices made at the same instant, each from
10 * its own short list — is flattened into independent categoricals, so one plain
11 * PPO loop drives it and the action space never explodes into the product of
12 * its parts.
1213 *
13 * Everything is a flat Float32Array. Forward keeps the activations because the
14 * backward pass in ppo.ts needs them; inference throws them away.
14 * What an observation holds and what a head means are the game's and are not
15 * named in here: it fills a `Float32Array` of its own length and reads an
16 * `Int32Array` of its own heads. Everything is flat, and nothing allocates once
17 * the arrays exist. `forward` keeps its activations because a backward pass
18 * needs them; inference throws them away.
1519 */
1620
1721 export type Shape = {
@@ -204,7 +208,7 @@ export function greedy(shape: Shape, logits: Float32Array, actions: Int32Array):
204208 /**
205209 * Weights ship to the browser quantised to one byte each, per tensor, with a
206210 * scale: a trained policy is ~8 kB instead of ~31 kB, and the error it adds is
207 * far below the noise the bots' own aim wobble puts on top. Inference runs on
211 * far below the noise a game's own randomness puts on top. Inference runs on
208212 * the dequantised floats, so it costs nothing per tick.
209213 */
210214 export type Packed = { shape: Shape; scales: number[]; bytes: string };
src/games.ts +9
@@ -78,6 +78,15 @@ export type Game = {
7878 * test helper and ships in neither bundle, so the app is the same size to the
7979 * byte and differs only in which short names the minifier handed out. Both
8080 * fit, so neither ceiling moved.
81 * #106 moved the policy network behind the door — `src/engine/net.ts`, which the
82 * shooter's bots run every tick and the trainer trains — and neither bundle
83 * gained a line of it: the app is 71,646 raw again, to the byte, and the range
84 * grew 34 raw / 18 gzipped bytes, which is the minifier handing out different
85 * short names over a module graph that changed shape. The range ships none of
86 * the net and its ceiling is the proof: `Math.tanh`, `atob` and `btoa` appear in
87 * the app's bundle and in neither line of the range's. Measured 2026-09-18 after
88 * #106: app 71,646 raw / 33,273 gzipped, range 27,268 raw / 11,674 gzipped.
89 * Both fit, so neither ceiling moved.
8190 */
8291 export const GAMES: readonly Game[] = [
8392 {
src/range/bundle.test.ts +15 −1
@@ -15,7 +15,8 @@
1515 */
1616
1717 import { afterAll, expect, test } from "bun:test";
18 import { discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
18 import { readFileSync } from "node:fs";
19 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
1920 import { page, type RangePage } from "./stage.test-helper.ts";
2021
2122 afterAll(discardBundleCopies);
@@ -112,6 +113,19 @@ test("the bundled entry draws the room and the plates when the browser has WebGL
112113 }
113114 });
114115
116 test("the range bundles none of the engine's policy network", () => {
117 // A tool set ships whole to a game that uses any of it, so the way to say the
118 // range uses none of the net is to read the file. Three names the minifier
119 // cannot rename and only that module has: the activation, and the two halves
120 // of its base64. The shooter's bundle has two of the three — it decodes
121 // weights and never quantises any, which `src/client/bundle.test.ts` checks —
122 // and this one has none of them.
123 const code = readFileSync(bundlePath("range"), "utf8");
124 expect(code.length).toBeGreaterThan(1000); // the reader is alive
125 expect(code).toContain("webgl2");
126 for (const name of ["Math.tanh", "atob", "btoa"]) expect(code).not.toContain(name);
127 });
128
115129 test("the stub page leaves nothing of itself behind", () => {
116130 // Every boot above undid its install, so no other test inherits a document.
117131 expect("document" in globalThis).toBe(HAD_DOCUMENT);
src/rl/learned.test.ts +1 −1
@@ -1,8 +1,8 @@
11 import { expect, test } from "bun:test";
22 import { decode, HEADS } from "../client/action.ts";
33 import { botThink, makeBrain, policy } from "../client/bots.ts";
4 import { forward, greedy, newActivations } from "../engine/index.ts";
45 import { OBS_SIZE } from "../client/observe.ts";
5 import { forward, greedy, newActivations } from "../client/policy.ts";
66 import { createArena, EPISODE_TICKS, observeLearner, scriptedFoe, step, stepInput } from "./arena.ts";
77
88 /**
src/rl/ppo.test.ts +2 −3
@@ -1,8 +1,7 @@
11 import { expect, test } from "bun:test";
2 import { rng } from "../engine/index.ts";
32 import {
4 createPolicy, forward, newActivations, tensorsOf, type Policy, type Shape,
5 } from "../client/policy.ts";
3 createPolicy, forward, newActivations, rng, tensorsOf, type Policy, type Shape,
4 } from "../engine/index.ts";
65 import {
76 applyGrads, backward, computeAdvantages, createAdam, createRollout, zeroGrads, DEFAULT_HYPER,
87 } from "./ppo.ts";
src/rl/ppo.ts +1 −1
@@ -12,7 +12,7 @@
1212 import {
1313 forward, headOffset, logitCount, newActivations, softmax, zeroPolicy,
1414 tensorsOf, type Activations, type Policy, type Shape,
15 } from "../client/policy.ts";
15 } from "../engine/index.ts";
1616
1717 export type Hyper = {
1818 gamma: number;
static/app.98e3474985.js → static/app.fcd92e70fb.js renamed +5 −5

Diff not shown — 140,774 bytes of diff (limit 100,000). +5 −5

View file
static/range.37362a7d93.js added +58

Diff not shown — a line 15,483 characters wide (limit 2,000). +58

View file
static/range.74f7561b23.js deleted −58

Diff not shown — a line 15,449 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs