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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/106-the-brain-becomes-engine-the-policy-netw → main 1 commit fork
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. | ||
| 133 | 133 | yaw, a size and a vertical squash — so one uploaded mesh draws at any size, |
| 134 | 134 | which is how the range draws a plate of any half-width from the one cube it |
| 135 | 135 | 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). | |
| 137 | 142 | Three rules keep it a package, and a test holds each: the engine imports |
| 138 | 143 | nothing outside itself, it needs nothing from a browser to load, and |
| 139 | 144 | 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. | ||
| 200 | 205 | `DE_FORGE` — one value of `Level` and the only one that ships), |
| 201 | 206 | `physics`, `weapons`, `combat`, `match` (the rules), |
| 202 | 207 | `world` (the simulation), |
| 203 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, | |
| 208 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, | |
| 204 | 209 | `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and |
| 205 | 210 | tested: the WebGL2 is the engine's now, and this game has no renderer of its |
| 206 | 211 | own. The level is handed in rather than imported: `createWorld(level, …)` |
| @@ -487,6 +492,11 @@ now a policy learned with PPO. | ||
| 487 | 492 | structured action into independent heads instead of one 3402-way choice is the |
| 488 | 493 | trick PufferLib calls emulation, and it is why one plain PPO loop drives all |
| 489 | 494 | 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. | |
| 490 | 500 | - **Where it learned** — `src/rl/arena.ts`, a duel on de_forge with the real |
| 491 | 501 | movement, the real bullets and the real damage, and no round, economy or bomb. |
| 492 | 502 | 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"; | ||
| 15 | 15 | import { join } from "node:path"; |
| 16 | 16 | import { HEADS, decode } from "../src/client/action.ts"; |
| 17 | 17 | 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"; | |
| 20 | 18 | import { |
| 21 | copyPolicy, createPolicy, forward, greedy, newActivations, pack, sample, unpack, | |
| 19 | copyPolicy, createPolicy, forward, greedy, newActivations, pack, rng, sample, unpack, | |
| 22 | 20 | 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"; | |
| 24 | 23 | import { |
| 25 | 24 | createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, reset, scriptedFoe, SKIP, |
| 26 | 25 | step, stepInput, stillFoe, type Arena, |
| @@ -277,7 +276,7 @@ function weightsModule(packed: ReturnType<typeof pack>, card: Card): string { | ||
| 277 | 276 | * that produced them measured, and it is what the page and the terminal show. |
| 278 | 277 | */ |
| 279 | 278 | |
| 280 | import type { Packed } from "./policy.ts"; | |
| 279 | import type { Packed } from "../engine/index.ts"; | |
| 281 | 280 | |
| 282 | 281 | export type Card = { |
| 283 | 282 | 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 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | 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"; | |
| 16 | 19 | import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts"; |
| 17 | 20 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 18 | import { | |
| 19 | forward, newActivations, sample, unpack, type Activations, type Policy, | |
| 20 | } from "./policy.ts"; | |
| 21 | 21 | import { CARD, PACKED } from "./weights.ts"; |
| 22 | 22 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 23 | 23 | import type { Input, Player, World } from "./world.ts"; |
src/client/bundle.test.ts +15 −1
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | import { afterAll, expect, test } from "bun:test"; |
| 22 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 22 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 23 | 23 | import { tmpdir } from "node:os"; |
| 24 | 24 | import { join } from "node:path"; |
| 25 | 25 | 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 | ||
| 174 | 174 | } |
| 175 | 175 | }); |
| 176 | 176 | |
| 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 | ||
| 177 | 191 | test("the stub page leaves nothing of itself behind", () => { |
| 178 | 192 | // Every boot above undid its install, so no other test inherits a document — |
| 179 | 193 | // 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 @@ | ||
| 6 | 6 | * that produced them measured, and it is what the page and the terminal show. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import type { Packed } from "./policy.ts"; | |
| 9 | import type { Packed } from "../engine/index.ts"; | |
| 10 | 10 | |
| 11 | 11 | export type Card = { |
| 12 | 12 | trainedAt: string; |
src/engine/GUIDE.md +47
| @@ -394,6 +394,52 @@ half lives beside your entry: `src/client/stage.test-helper.ts` and | ||
| 394 | 394 | It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts` |
| 395 | 395 | reads both committed bundles to say so. |
| 396 | 396 | |
| 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 | ||
| 397 | 443 | ## The seam, named as pairs |
| 398 | 444 | |
| 399 | 445 | 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. | ||
| 414 | 460 | | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` | |
| 415 | 461 | | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays | |
| 416 | 462 | | 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 | | |
| 417 | 464 | |
| 418 | 465 | ## What the engine deliberately does not know |
| 419 | 466 |
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", ( | ||
| 231 | 231 | /** |
| 232 | 232 | * The surface. This list is the engine's public API written down: a name that |
| 233 | 233 | * 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`. | |
| 235 | 243 | * |
| 236 | 244 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one |
| 237 | 245 | * copy of the surface, read twice, rather than two that drift. |
| 238 | 246 | */ |
| 239 | 247 | export const SURFACE = [ |
| 248 | "Activations", | |
| 240 | 249 | "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", |
| 241 | 250 | "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", |
| 242 | 251 | "Frames", |
| 243 | 252 | "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", | |
| 245 | 255 | "RendererAssets", |
| 246 | "Scene", "SceneCapacity", "Shader", "SoundDevice", "Stick", "Surface", "Tone", | |
| 256 | "Scene", "SceneCapacity", "Shader", "Shape", "SoundDevice", "Stick", "Surface", "Tone", | |
| 247 | 257 | "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", | |
| 250 | 262 | "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", | |
| 253 | 269 | "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", | |
| 257 | 274 | ]; |
| 258 | 275 | |
| 259 | 276 | 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_$]*$/; | ||
| 61 | 61 | * this file ignores without needing to be told about it. |
| 62 | 62 | */ |
| 63 | 63 | 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", | |
| 67 | 67 | ]); |
| 68 | 68 | |
| 69 | 69 | const guide = (): string => readFileSync(GUIDE, "utf8"); |
src/engine/index.ts +7
| @@ -76,3 +76,10 @@ export { fakePage } from "./page.ts"; | ||
| 76 | 76 | export type { |
| 77 | 77 | FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions, |
| 78 | 78 | } 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 | ||
| 1 | 13 | 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"; | |
| 4 | 15 | import { |
| 5 | 16 | createPolicy, forward, greedy, headOffset, logitCount, newActivations, pack, paramCount, |
| 6 | 17 | sample, softmax, unpack, type Shape, |
| 7 | } from "./policy.ts"; | |
| 8 | import { PACKED } from "./weights.ts"; | |
| 18 | } from "./net.ts"; | |
| 9 | 19 | |
| 10 | 20 | const SHAPE: Shape = { obs: 4, hidden: 5, heads: [3, 2] }; |
| 11 | 21 | |
| 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 | ||
| 12 | 25 | test("a fresh policy is nearly uniform, so nothing is believed before it is learned", () => { |
| 13 | 26 | const p = createPolicy(SHAPE, rng(11)); |
| 14 | 27 | 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", () => { | ||
| 74 | 87 | expect(worst).toBeLessThanOrEqual(scale / 127 / 2 + 1e-6); |
| 75 | 88 | expect(back.shape).toEqual(p.shape); |
| 76 | 89 | }); |
| 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | * |
| 5 | 5 | * obs -> tanh(W1) -> tanh(W2) -> logits (several categorical heads) |
| 6 | 6 | * \-> value (one number, training only) |
| 7 | 7 | * |
| 8 | 8 | * 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. | |
| 12 | 13 | * |
| 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. | |
| 15 | 19 | */ |
| 16 | 20 | |
| 17 | 21 | export type Shape = { |
| @@ -204,7 +208,7 @@ export function greedy(shape: Shape, logits: Float32Array, actions: Int32Array): | ||
| 204 | 208 | /** |
| 205 | 209 | * Weights ship to the browser quantised to one byte each, per tensor, with a |
| 206 | 210 | * 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 | |
| 208 | 212 | * the dequantised floats, so it costs nothing per tick. |
| 209 | 213 | */ |
| 210 | 214 | export type Packed = { shape: Shape; scales: number[]; bytes: string }; |
src/games.ts +9
| @@ -78,6 +78,15 @@ export type Game = { | ||
| 78 | 78 | * test helper and ships in neither bundle, so the app is the same size to the |
| 79 | 79 | * byte and differs only in which short names the minifier handed out. Both |
| 80 | 80 | * 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. | |
| 81 | 90 | */ |
| 82 | 91 | export const GAMES: readonly Game[] = [ |
| 83 | 92 | { |
src/range/bundle.test.ts +15 −1
| @@ -15,7 +15,8 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | 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"; | |
| 19 | 20 | import { page, type RangePage } from "./stage.test-helper.ts"; |
| 20 | 21 | |
| 21 | 22 | afterAll(discardBundleCopies); |
| @@ -112,6 +113,19 @@ test("the bundled entry draws the room and the plates when the browser has WebGL | ||
| 112 | 113 | } |
| 113 | 114 | }); |
| 114 | 115 | |
| 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 | ||
| 115 | 129 | test("the stub page leaves nothing of itself behind", () => { |
| 116 | 130 | // Every boot above undid its install, so no other test inherits a document. |
| 117 | 131 | expect("document" in globalThis).toBe(HAD_DOCUMENT); |
src/rl/learned.test.ts +1 −1
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { decode, HEADS } from "../client/action.ts"; |
| 3 | 3 | import { botThink, makeBrain, policy } from "../client/bots.ts"; |
| 4 | import { forward, greedy, newActivations } from "../engine/index.ts"; | |
| 4 | 5 | import { OBS_SIZE } from "../client/observe.ts"; |
| 5 | import { forward, greedy, newActivations } from "../client/policy.ts"; | |
| 6 | 6 | import { createArena, EPISODE_TICKS, observeLearner, scriptedFoe, step, stepInput } from "./arena.ts"; |
| 7 | 7 | |
| 8 | 8 | /** |
src/rl/ppo.test.ts +2 −3
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { rng } from "../engine/index.ts"; | |
| 3 | 2 | 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"; | |
| 6 | 5 | import { |
| 7 | 6 | applyGrads, backward, computeAdvantages, createAdam, createRollout, zeroGrads, DEFAULT_HYPER, |
| 8 | 7 | } from "./ppo.ts"; |
src/rl/ppo.ts +1 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | import { |
| 13 | 13 | forward, headOffset, logitCount, newActivations, softmax, zeroPolicy, |
| 14 | 14 | tensorsOf, type Activations, type Policy, type Shape, |
| 15 | } from "../client/policy.ts"; | |
| 15 | } from "../engine/index.ts"; | |
| 16 | 16 | |
| 17 | 17 | export type Hyper = { |
| 18 | 18 | 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 filestatic/range.37362a7d93.js added +58
Diff not shown — a line 15,483 characters wide (limit 2,000). +58
View filestatic/range.74f7561b23.js deleted −58
Diff not shown — a line 15,449 characters wide (limit 2,000). −58
View file
new above
offline