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

forked from trevato/cs · proposes · 4 behind

The trainer becomes engine: PPO through the door, proved on an environment that is not a game #126

Merged opened this

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

Merged

torque/cs-engine:torque/107-the-trainer-becomes-engine-ppo-through-t → main 2 commits fork

  • Merged. torque/107-the-trainer-becomes-engine-ppo-through-t was merged into main as 6ad116c.

Review

Sign in to review this change.
15 files changed +402 −256
README.md +15 −9
@@ -45,7 +45,8 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
4545 published shape so the habits transfer, and the rest is original.
4646 - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer
4747 policy decides where to point, when to shoot and which way to move, trained with
48 PPO in `src/rl` against millions of ticks of this same simulation. The numbers on
48 the engine's PPO against millions of ticks of this same simulation, in the arena
49 under `src/rl`. The numbers on
4950 the page and in the terminal's `policy` tab are what the run that produced the
5051 shipped weights measured, on the same fixed duels for the policy and for the
5152 scripted bots it replaced.
@@ -402,10 +403,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
402403 `bundle.test.ts` boots the committed bundle and reads it for what is *not* in
403404 it (`webgl2`, `AudioContext`, `Math.tanh`): a door is only worth having if
404405 importing it costs what you use.
405 - `src/rl/` — training, which no game module imports: `arena` is the environment
406 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
407 the algorithm, with its backward pass checked against finite differences.
408 `scripts/train.ts` runs them and writes `src/client/weights.ts`. It is the
406 - `src/rl/` — training, which no game module imports: `arena` is the environment,
407 a real duel on de_forge with the round and the economy taken out. The algorithm
408 is not in here — PPO is `src/engine/learn.ts`, behind the door, because nothing
409 in it is about shooting; what is here is this game's arena.
410 `scripts/train.ts` runs the two together and writes `src/client/weights.ts`. It is the
409411 *shooter's* training and `src/independence.test.ts` counts it as the shooter:
410412 it reads `src/client/` module by module because that game is the environment,
411413 and for the same reason it may not read `src/range/`.
@@ -571,10 +573,14 @@ now a policy learned with PPO.
571573 movement, the real bullets and the real damage, and no round, economy or bomb.
572574 It steps about 13,000 ticks a second on one core, allocation-free; the policy
573575 decides every fourth tick and the decision is held in between.
574 - **How it learned** — `src/rl/ppo.ts`, about three hundred lines: GAE, a
575 clipped surrogate, an entropy bonus, Adam, and a backward pass written out by
576 hand and checked against finite differences in the tests. There is a
577 curriculum on how far apart the duel starts and on how hard the opponent is,
576 - **How it learned** — `src/engine/learn.ts`, about three hundred lines behind
577 the engine's door: GAE, a clipped surrogate, an entropy bonus, Adam, and a
578 backward pass written out by hand and checked against finite differences in
579 the tests. It is the engine's rather than this game's because nothing in it
580 names a map, a tick or a weapon — the same door trains a two-armed bandit
581 written inside `src/engine/learn.test.ts`, which is how that claim is checked.
582 The curriculum on top of it is the shooter's and lives in `scripts/train.ts`:
583 there is a curriculum on how far apart the duel starts and on how hard the opponent is,
578584 because handed a good opponent from the first step the policy learns to hide
579585 and never comes back from it.
580586 - **What it costs you** — about eight kilobytes of int8 in the bundle, one
scripts/train.ts +7 −5
@@ -9,6 +9,10 @@
99 * Nothing here is imported by the server or the page: this file runs on a
1010 * machine with a few minutes to spare, and its only output is a table of
1111 * numbers and about eight kilobytes of int8.
12 *
13 * The PPO is the engine's, reached through the door like everything else it
14 * offers; what is the shooter's is the arena it collects from, the observation
15 * it fills, the heads it decodes and the curriculum below.
1216 */
1317
1418 import { writeFileSync } from "node:fs";
@@ -16,17 +20,15 @@ import { join } from "node:path";
1620 import { HEADS, decode } from "../src/client/action.ts";
1721 import { botThink, makeBrain } from "../src/client/bots.ts";
1822 import {
19 copyPolicy, createPolicy, forward, greedy, newActivations, pack, rng, sample, unpack,
20 type Policy, type Shape,
23 computeAdvantages, copyPolicy, createAdam, createPolicy, createRollout, forward, greedy,
24 newActivations, pack, rng, sample, unpack, update, DEFAULT_HYPER,
25 type Hyper, type Policy, type Shape,
2126 } from "../src/engine/index.ts";
2227 import { OBS_SIZE } from "../src/client/observe.ts";
2328 import {
2429 createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, reset, scriptedFoe, SKIP,
2530 step, stepInput, stillFoe, type Arena,
2631 } from "../src/rl/arena.ts";
27 import {
28 computeAdvantages, createAdam, createRollout, update, DEFAULT_HYPER, type Hyper,
29 } from "../src/rl/ppo.ts";
3032
3133 export const SHAPE: Shape = { obs: OBS_SIZE, hidden: 64, heads: HEADS };
3234
src/engine/GUIDE.md +49 −1
@@ -420,7 +420,7 @@ It is the one group behind the door that a game may reasonably never open — th
420420 shooter runs it every tick to fight with, and the range ships not a byte of it —
421421 and it is here because nothing in it is about shooting. The example does not use
422422 it either, which is why this section has no code block: read
423 `src/client/bots.ts` for a policy driving a game and `src/rl/ppo.ts` for one
423 `src/client/bots.ts` for a policy driving a game and `scripts/train.ts` for one
424424 being trained.
425425
426426 **What a game brings first is a `Shape`:** how many numbers its observation has,
@@ -459,6 +459,54 @@ are and what each one is worth is `src/client/observe.ts`; which head is a turn
459459 and which is a trigger is `src/client/action.ts`; both are the shooter's, and a
460460 second game that wanted an agent would write its own two and share this.
461461
462 ### learn
463
464 PPO: how the weights the section above ships got their numbers. **A game needs
465 none of this.** A game ships a trained `Policy` and runs `forward`; training
466 happens once, on a machine with minutes to spare, in a script nothing serves.
467 Nothing in here ever reaches a browser — no committed bundle carries a byte of
468 it, which is a ceiling in `src/games.ts` and a test that reads the files — and
469 it is behind the door for one reason: not a line of it is about shooting. The
470 proof is in `src/engine/learn.test.ts`, which trains a two-armed bandit written
471 out inside the test, with no map, no tick and no weapon anywhere in it.
472
473 **What a caller brings is an environment and a `Rollout`.** `createRollout`
474 takes a `Shape`, how many environments are stepped side by side and how many
475 steps each one contributes, and hands back the flat arrays one batch of
476 experience is: the observations end to end, the action each head took, the
477 log-probability `sample` returned, the value `forward` predicted, the reward
478 that came back and whether the step ended an episode. The caller fills those by
479 stepping its own world — the engine never sees the world, only the numbers — and
480 writes into `Rollout.lastValues` what the policy thinks each unfinished episode
481 is still worth. Then `computeAdvantages` walks the slab backwards per
482 environment, discounting by `Hyper.gamma` and `Hyper.lambda` and never looking
483 through the seam where an episode ended, and fills in the advantages and the
484 returns.
485
486 **Then `update` is the step itself:** a few epochs of shuffled minibatches over
487 that rollout, each one a clipped surrogate with an entropy bonus and a value
488 loss, ending in an Adam step. It takes the policy, the rollout, an `Adam` from
489 `createAdam` and the same seeded generator the rollout was sampled with, and
490 returns an `UpdateStats` — the policy loss, the value loss, the entropy, the
491 fraction of samples that clipped and the gradient norm — which is the line a
492 training script prints and the first place a run that has gone wrong shows it.
493
494 **The tuning is data the caller passes.** `Hyper` is the nine numbers — the two
495 discounts, the clip, the entropy bonus, the value coefficient, the learning
496 rate, the epochs, the minibatch and the gradient-norm clip — and `DEFAULT_HYPER`
497 is a starting point rather than a recommendation: `createAdam` is handed the one
498 the caller means, and the shooter spreads `DEFAULT_HYPER` and overrides the
499 minibatch for its batch size. No constant in this module is the shooter's.
500
501 The pieces under those are exported too, because the hand-written backward pass
502 is the part most worth testing from outside: `Grads` is a `Policy` full of
503 gradients, `zeroGrads` makes one, `clearGrads` empties it between minibatches,
504 `backward` accumulates one sample into it given the derivative of the loss with
505 respect to the logits and the value, and `applyGrads` clips the global norm and
506 takes the Adam step, returning the norm it saw. The gradient check in
507 `src/engine/learn.test.ts` holds all of that against finite differences, which
508 is the only thing that catches an autodiff-free derivative being subtly wrong.
509
462510 ## The seam, named as pairs
463511
464512 Two games ship here, and every place they differ is a place the engine chose to
src/engine/boundary.test.ts +25 −29
@@ -1,8 +1,8 @@
11 /**
22 * The boundary. `src/engine/` is the part a second game could use, so a module
33 * in here may import the engine and nothing else: not the game in
4 * `src/client/`, not the training in `src/rl/`, and no package — the engine has
5 * nothing to install. This test reads the sources rather than trusting the
4 * `src/client/`, not the arena in `src/rl/` — the trainer is in here, what it
5 * trains on is not — and no package, because the engine has nothing to install. This test reads the sources rather than trusting the
66 * convention, because an import is one line and nobody reviews a line that
77 * compiles.
88 *
@@ -146,7 +146,7 @@ test("the engine has the sources this test is about", () => {
146146 expect(files).toContain("trace.ts");
147147 });
148148
149 test("no engine module imports the game, the training, or a package", () => {
149 test("no engine module imports the game, the arena, or a package", () => {
150150 const offences: string[] = [];
151151 for (const file of sources()) {
152152 const source = readFileSync(join(ENGINE, file), "utf8");
@@ -274,36 +274,32 @@ test("the example imports the door and nothing else, which is what it is for", (
274274 * `newActivations`, `forward`, `headOffset`, `softmax`, `sample`, `greedy`,
275275 * `pack`, `unpack`.
276276 *
277 * learn — `Hyper`, `DEFAULT_HYPER`, `Grads`, `zeroGrads`, `clearGrads`,
278 * `backward`, `Adam`, `createAdam`, `applyGrads`, `Rollout`, `createRollout`,
279 * `computeAdvantages`, `update`, `UpdateStats`.
280 *
277281 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
278282 * copy of the surface, read twice, rather than two that drift.
279283 */
280284 export const SURFACE = [
281 "Activations",
282 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor",
283 "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow",
284 "Frames",
285 "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions",
286 "Mat4", "Mesh", "MouseEventish", "Options", "Overlay",
287 "Packed", "PageOptions", "Paint", "Painter", "Policy", "Renderer",
288 "RendererAssets",
289 "Scene", "SceneCapacity", "Shader", "Shape", "SoundDevice", "Stick", "Surface", "Tone",
290 "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors",
291 "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf",
292 "copyPolicy", "createControls",
293 "createLoop", "createPolicy", "createRenderer", "createScene", "createSoundDevice", "cross",
294 "deg", "dist",
295 "dist2d", "dot", "fakePage", "fbm",
296 "findPath", "finish",
297 "forward", "greedy", "hash", "headOffset", "inside", "length", "length2d", "lerp",
298 "logitCount", "mat4", "mergeSolid",
299 "modelMatrix", "mul", "multiply",
300 "newActivations", "nextInstance", "noise", "normalize", "overlay",
301 "pack", "paintAtlas", "painter", "paramCount",
302 "perspective", "pushLine",
303 "quadMesh", "rad", "rayBox", "resetScene", "rng", "rowOf",
304 "sample", "scale", "segmentLength", "softmax", "sub", "tensorsOf",
305 "traceWorld", "unpack",
306 "v3", "viewMatrix", "visible", "worldOf", "zeroPolicy",
285 "Activations", "Adam", "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera",
286 "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas", "FakeDocument", "FakeElement", "FakePage",
287 "FakePaint", "FakeTarget", "FakeWindow", "Frames", "Grads", "Grid", "Hit", "Host", "Hyper",
288 "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh",
289 "MouseEventish", "Options", "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Policy",
290 "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape",
291 "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3",
292 "add", "addBox", "angleDelta", "angleVectors", "applyGrads", "attachButtons", "attenuate",
293 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "computeAdvantages",
294 "copyPolicy", "createAdam", "createControls", "createLoop", "createPolicy", "createRenderer",
295 "createRollout", "createScene", "createSoundDevice", "cross", "deg", "dist", "dist2d", "dot",
296 "fakePage", "fbm", "findPath", "finish", "forward", "greedy", "hash", "headOffset", "inside",
297 "length", "length2d", "lerp", "logitCount", "mat4", "mergeSolid", "modelMatrix", "mul",
298 "multiply", "newActivations", "nextInstance", "noise", "normalize", "overlay", "pack",
299 "paintAtlas", "painter", "paramCount", "perspective", "pushLine", "quadMesh", "rad", "rayBox",
300 "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "sub",
301 "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf",
302 "zeroGrads", "zeroPolicy",
307303 ];
308304
309305 test("the door offers exactly the names written down here", () => {
src/engine/index.ts +11
@@ -83,3 +83,14 @@ export {
8383 paramCount, sample, softmax, tensorsOf, unpack, zeroPolicy,
8484 } from "./net.ts";
8585 export type { Activations, Packed, Policy, Shape } from "./net.ts";
86
87 /**
88 * PPO over a policy: a rollout a caller filled in, a clipped update out. A game
89 * needs none of this — it ships weights, not a trainer — and no browser loads
90 * it: the committed bundles carry not a byte of this module.
91 */
92 export {
93 applyGrads, backward, clearGrads, computeAdvantages, createAdam, createRollout, update,
94 zeroGrads, DEFAULT_HYPER,
95 } from "./learn.ts";
96 export type { Adam, Grads, Hyper, Rollout, UpdateStats } from "./learn.ts";
src/engine/learn.test.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * The trainer, checked from the two ends that can catch it being wrong: the
3 * backward pass against finite differences, and a whole run against something
4 * that learns. Everything here reaches the trainer through `index.ts`, the way
5 * a caller outside the engine has to — the door is the surface under test.
6 */
7
8 import { expect, test } from "bun:test";
9 import { readFileSync, readdirSync } from "node:fs";
10 import { join, resolve } from "node:path";
11 import {
12 applyGrads, backward, computeAdvantages, createAdam, createPolicy, createRollout, forward,
13 newActivations, rng, sample, tensorsOf, update, zeroGrads, DEFAULT_HYPER,
14 type Hyper, type Policy, type Shape,
15 } from "./index.ts";
16
17 const SHAPE: Shape = { obs: 5, hidden: 6, heads: [3, 2] };
18
19 /**
20 * The backward pass is written by hand, so it is checked against the only
21 * thing that cannot be wrong in the same way: the function itself, evaluated
22 * twice a hair apart.
23 */
24 test("the analytic gradient matches finite differences everywhere", () => {
25 const random = rng(4242);
26 const p = createPolicy(SHAPE, random);
27 // A weighted sum of the outputs, so dLoss/dLogits and dLoss/dValue are known.
28 const dLogits = new Float32Array([0.7, -0.3, 0.2, -0.9, 0.4]);
29 const dValue = 0.6;
30 const obs = new Float32Array(SHAPE.obs);
31 for (let i = 0; i < obs.length; i++) obs[i] = random() * 2 - 1;
32
33 const loss = (policy: Policy): number => {
34 const a = forward(policy, obs, newActivations(SHAPE));
35 let sum = dValue * a.value;
36 for (let i = 0; i < dLogits.length; i++) sum += (dLogits[i] as number) * (a.logits[i] as number);
37 return sum;
38 };
39
40 const g = zeroGrads(SHAPE);
41 const a = forward(p, obs, newActivations(SHAPE));
42 backward(p, obs, 0, a, dLogits, dValue, g, {
43 dh1: new Float32Array(SHAPE.hidden),
44 dh2: new Float32Array(SHAPE.hidden),
45 });
46
47 const params = tensorsOf(p);
48 const grads = tensorsOf(g);
49 const eps = 1e-3;
50 let checked = 0;
51 for (let k = 0; k < params.length; k++) {
52 const t = params[k] as Float32Array;
53 const gt = grads[k] as Float32Array;
54 for (let i = 0; i < t.length; i += Math.max(1, Math.floor(t.length / 7))) {
55 const original = t[i] as number;
56 t[i] = original + eps;
57 const up = loss(p);
58 t[i] = original - eps;
59 const down = loss(p);
60 t[i] = original;
61 const numeric = (up - down) / (2 * eps);
62 expect(Math.abs(numeric - (gt[i] as number))).toBeLessThan(2e-3 + Math.abs(numeric) * 5e-3);
63 checked += 1;
64 }
65 }
66 expect(checked).toBeGreaterThan(30);
67 });
68
69 test("advantages telescope backwards and stop at the end of an episode", () => {
70 const r = createRollout(SHAPE, 1, 3);
71 r.rewards.set([1, 1, 1]);
72 r.values.set([0, 0, 0]);
73 r.dones.set([0, 1, 0]);
74 r.lastValues[0] = 0;
75 computeAdvantages(r, 1, 1);
76 // Step 2 is the last and is not terminal, so it bootstraps from lastValues=0.
77 expect(r.advantages[2]).toBeCloseTo(1, 6);
78 // Step 1 ended the episode: its advantage is its own reward and nothing past it.
79 expect(r.advantages[1]).toBeCloseTo(1, 6);
80 // Step 0 sees step 1 through the discount, but not step 2 through the seam.
81 expect(r.advantages[0]).toBeCloseTo(2, 6);
82 expect(r.returns[0]).toBeCloseTo(2, 6);
83 });
84
85 test("a gradient step moves the parameters and the norm is reported", () => {
86 const p = createPolicy(SHAPE, rng(7));
87 const g = zeroGrads(SHAPE);
88 g.b1.fill(1);
89 const before = (p.b1[0] as number);
90 const adam = createAdam(p, DEFAULT_HYPER);
91 const norm = applyGrads(p, g, adam, 1);
92 expect(norm).toBeCloseTo(Math.sqrt(SHAPE.hidden), 5);
93 // Adam's first step is about one learning rate, whatever the gradient size.
94 expect(before - (p.b1[0] as number)).toBeCloseTo(DEFAULT_HYPER.learningRate, 5);
95 });
96
97 // --- an environment that is not a game ------------------------------------
98
99 /**
100 * The proof that this module is the engine's rather than the shooter's: an
101 * environment written out in this file, with no map, no tick and no weapon in
102 * it, learned through the door and nothing else.
103 *
104 * It is a two-armed bandit with the answer in plain sight: the observation is
105 * which arm pays this pull, one-hot, and pulling that arm pays 1 and the other
106 * pays 0. A policy that ignores its observation scores a half however long it
107 * trains, so the floor below is not something a drifting bias reaches — the
108 * observation has to arrive at the logits through both layers for the number to
109 * move. Every pull is its own episode, so GAE has nothing to telescope and what
110 * is being tested is the surrogate and the optimiser.
111 *
112 * Small on purpose: 128 pulls a rollout, a hidden layer of eight, 40 updates,
113 * which is a few hundredths of a second. The seed is fixed and nothing here
114 * reads a clock, so the numbers it produces are the same on every machine.
115 */
116 const BANDIT: Shape = { obs: 2, hidden: 8, heads: [2] };
117 const BANDIT_ENVS = 8;
118 const BANDIT_STEPS = 16;
119 const BANDIT_UPDATES = 40;
120
121 /** Train on the bandit from `seed`, and report the mean reward of each rollout. */
122 function trainBandit(seed: number): number[] {
123 const random = rng(seed);
124 // A bandit is not a duel: the batch is small, so the minibatch is the batch,
125 // and the step is the one a toy can afford rather than the default.
126 const hyper: Hyper = { ...DEFAULT_HYPER, learningRate: 0.02, minibatch: 64 };
127 const policy = createPolicy(BANDIT, random);
128 const adam = createAdam(policy, hyper);
129 const rollout = createRollout(BANDIT, BANDIT_ENVS, BANDIT_STEPS);
130 const act = newActivations(BANDIT);
131 const actions = new Int32Array(1);
132 const means: number[] = [];
133
134 for (let iter = 0; iter < BANDIT_UPDATES; iter++) {
135 let paid = 0;
136 for (let t = 0; t < BANDIT_STEPS; t++)
137 for (let e = 0; e < BANDIT_ENVS; e++) {
138 const i = t * BANDIT_ENVS + e;
139 const paying = random() < 0.5 ? 0 : 1;
140 rollout.obs[i * BANDIT.obs] = paying === 0 ? 1 : 0;
141 rollout.obs[i * BANDIT.obs + 1] = paying === 1 ? 1 : 0;
142 forward(policy, rollout.obs, act, i * BANDIT.obs);
143 rollout.values[i] = act.value;
144 rollout.logProbs[i] = sample(BANDIT, act.logits, random, actions);
145 const pulled = actions[0] as number;
146 rollout.actions[i] = pulled;
147 rollout.rewards[i] = pulled === paying ? 1 : 0;
148 rollout.dones[i] = 1;
149 paid += pulled === paying ? 1 : 0;
150 }
151 computeAdvantages(rollout, hyper.gamma, hyper.lambda);
152 update(policy, rollout, adam, random);
153 means.push(paid / (BANDIT_ENVS * BANDIT_STEPS));
154 }
155 return means;
156 }
157
158 test("the trainer teaches an environment that has never heard of this game", () => {
159 const means = trainBandit(20260920);
160 expect(means.length).toBe(BANDIT_UPDATES);
161 // It starts where a coin starts: a fresh policy is nearly uniform.
162 expect(means[0] as number).toBeGreaterThan(0.35);
163 expect(means[0] as number).toBeLessThan(0.65);
164 // And ends past a floor no observation-blind policy reaches, on the rollout
165 // the updates were collected from — not a separate greedy evaluation, so the
166 // number includes every pull the policy still spends exploring.
167 const last = means[BANDIT_UPDATES - 1] as number;
168 expect(last).toBeGreaterThan(0.9);
169 // The rise is the run's, not one lucky rollout's: the last five average high.
170 const tail = means.slice(-5).reduce((a, b) => a + b, 0) / 5;
171 expect(tail).toBeGreaterThan(0.9);
172 });
173
174 test("the same seed trains the same way, and another seed learns too", () => {
175 expect(trainBandit(20260920)).toEqual(trainBandit(20260920));
176 const other = trainBandit(7);
177 expect(other.slice(-5).reduce((a, b) => a + b, 0) / 5).toBeGreaterThan(0.9);
178 });
179
180 /**
181 * The door re-exports a module no browser will ever load, and this is what says
182 * so: the committed bundles, read the way `page.test.ts` reads them. A game
183 * ships weights and runs them; if a byte of the trainer lands in a bundle the
184 * design is wrong, not this test.
185 */
186 test("no committed bundle carries the trainer", () => {
187 const dir = join(resolve(import.meta.dir, "..", ".."), "static");
188 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
189 expect(bundles.length).toBeGreaterThanOrEqual(3);
190 for (const file of bundles) {
191 const code = readFileSync(join(dir, file), "utf8");
192 // The reader is alive: a bundle is a bundle and not an empty file.
193 expect(code.length).toBeGreaterThan(1000);
194 // A minifier renames a variable and keeps a property, so these five are
195 // this module's fingerprint: the hyperparameters it reads, the field a
196 // caller bootstraps and the statistics it returns.
197 for (const key of ["maxGradNorm", "valueCoef", "lastValues", "clipFraction", "policyLoss"])
198 expect(code).not.toContain(key);
199 }
200 });
src/rl/ppo.ts → src/engine/learn.ts renamed +19 −4
@@ -6,13 +6,23 @@
66 *
77 * The backward pass is written out by hand because the network is three
88 * matrices; an autodiff library would be more code to maintain than this file.
9 * `gradientCheck` in the tests holds it honest against finite differences.
9 * The gradient check in the tests holds it honest against finite differences.
10 *
11 * Nothing in here knows what an environment is. It is handed a `Rollout` — a
12 * slab of numbers a caller filled by stepping something — and it hands back a
13 * changed `Policy` and a line of statistics; what the observations mean, what
14 * the actions do and where the rewards came from never arrive. `learn.test.ts`
15 * trains a two-armed bandit written in the test itself to prove that, and the
16 * game this repository ships trains through the same door with the same calls.
17 *
18 * No browser ever loads this: a game needs a trained `Policy`, not a trainer,
19 * and the committed bundles carry none of it.
1020 */
1121
1222 import {
1323 forward, headOffset, logitCount, newActivations, softmax, zeroPolicy,
1424 tensorsOf, type Activations, type Policy, type Shape,
15 } from "../engine/index.ts";
25 } from "./net.ts";
1626
1727 export type Hyper = {
1828 gamma: number;
@@ -26,12 +36,17 @@ export type Hyper = {
2636 maxGradNorm: number;
2737 };
2838
39 /**
40 * A starting point, not a recommendation: the numbers a caller gets when it
41 * has no opinion yet. Tuning is the caller's, and `update` reads the `Hyper`
42 * it was handed rather than these, so a game's settings never leak in here.
43 */
2944 export const DEFAULT_HYPER: Hyper = {
3045 gamma: 0.99,
3146 lambda: 0.95,
3247 clip: 0.2,
33 // Summed over six heads, so it is worth six times what the same number means
34 // for a single head: at 0.01 the bots never stop twitching long enough to aim.
48 // The entropy bonus is summed over every head, so it is worth as many times
49 // this number as a policy has heads: a six-headed policy at 0.01 twitches.
3550 entropy: 0.003,
3651 valueCoef: 0.5,
3752 learningRate: 1e-3,
src/games.ts +13
@@ -114,6 +114,19 @@ export type Game = {
114114 * differing only in which short names the minifier handed out, because it
115115 * paints no texture and ships none of this. Both fit today's ceilings, so
116116 * neither moved.
117 * #107 moved PPO behind the door too — `src/engine/learn.ts`, which no browser
118 * will ever load — and not one of the three bundles gained a byte: app 72,048
119 * raw, range 28,978 raw, example 8,754 raw, each the same number as before it
120 * moved. The gzipped sizes moved by a byte or two (app 33,457 → 33,482, example
121 * 4,228 → 4,227) because the minifier handed out different short names over a
122 * module graph that changed shape, which is the same thing that happened for
123 * #106. That the trainer is absent is a test rather than an inference:
124 * `src/engine/learn.test.ts` reads every committed bundle for `maxGradNorm`,
125 * `valueCoef`, `lastValues`, `clipFraction` and `policyLoss` and finds none of
126 * them. Measured 2026-09-20 after #107, on the bundles this branch commits.
127 * The range's numbers here are #121's moving plates and #125's painter at once:
128 * both landed a range bundle and neither swept the other's, so static/ held two
129 * and every reader of it failed until a plain `bun run build` resolved them.
117130 */
118131 export const GAMES: readonly Game[] = [
119132 {
src/rl/ppo.test.ts deleted −87
@@ -1,87 +0,0 @@
1 import { expect, test } from "bun:test";
2 import {
3 createPolicy, forward, newActivations, rng, tensorsOf, type Policy, type Shape,
4 } from "../engine/index.ts";
5 import {
6 applyGrads, backward, computeAdvantages, createAdam, createRollout, zeroGrads, DEFAULT_HYPER,
7 } from "./ppo.ts";
8
9 const SHAPE: Shape = { obs: 5, hidden: 6, heads: [3, 2] };
10
11 /**
12 * The backward pass is written by hand, so it is checked against the only
13 * thing that cannot be wrong in the same way: the function itself, evaluated
14 * twice a hair apart.
15 */
16 test("the analytic gradient matches finite differences everywhere", () => {
17 const random = rng(4242);
18 const p = createPolicy(SHAPE, random);
19 // A weighted sum of the outputs, so dLoss/dLogits and dLoss/dValue are known.
20 const dLogits = new Float32Array([0.7, -0.3, 0.2, -0.9, 0.4]);
21 const dValue = 0.6;
22 const obs = new Float32Array(SHAPE.obs);
23 for (let i = 0; i < obs.length; i++) obs[i] = random() * 2 - 1;
24
25 const loss = (policy: Policy): number => {
26 const a = forward(policy, obs, newActivations(SHAPE));
27 let sum = dValue * a.value;
28 for (let i = 0; i < dLogits.length; i++) sum += (dLogits[i] as number) * (a.logits[i] as number);
29 return sum;
30 };
31
32 const g = zeroGrads(SHAPE);
33 const a = forward(p, obs, newActivations(SHAPE));
34 backward(p, obs, 0, a, dLogits, dValue, g, {
35 dh1: new Float32Array(SHAPE.hidden),
36 dh2: new Float32Array(SHAPE.hidden),
37 });
38
39 const params = tensorsOf(p);
40 const grads = tensorsOf(g);
41 const eps = 1e-3;
42 let checked = 0;
43 for (let k = 0; k < params.length; k++) {
44 const t = params[k] as Float32Array;
45 const gt = grads[k] as Float32Array;
46 for (let i = 0; i < t.length; i += Math.max(1, Math.floor(t.length / 7))) {
47 const original = t[i] as number;
48 t[i] = original + eps;
49 const up = loss(p);
50 t[i] = original - eps;
51 const down = loss(p);
52 t[i] = original;
53 const numeric = (up - down) / (2 * eps);
54 expect(Math.abs(numeric - (gt[i] as number))).toBeLessThan(2e-3 + Math.abs(numeric) * 5e-3);
55 checked += 1;
56 }
57 }
58 expect(checked).toBeGreaterThan(30);
59 });
60
61 test("advantages telescope backwards and stop at the end of an episode", () => {
62 const r = createRollout(SHAPE, 1, 3);
63 r.rewards.set([1, 1, 1]);
64 r.values.set([0, 0, 0]);
65 r.dones.set([0, 1, 0]);
66 r.lastValues[0] = 0;
67 computeAdvantages(r, 1, 1);
68 // Step 2 is the last and is not terminal, so it bootstraps from lastValues=0.
69 expect(r.advantages[2]).toBeCloseTo(1, 6);
70 // Step 1 ended the episode: its advantage is its own reward and nothing past it.
71 expect(r.advantages[1]).toBeCloseTo(1, 6);
72 // Step 0 sees step 1 through the discount, but not step 2 through the seam.
73 expect(r.advantages[0]).toBeCloseTo(2, 6);
74 expect(r.returns[0]).toBeCloseTo(2, 6);
75 });
76
77 test("a gradient step moves the parameters and the norm is reported", () => {
78 const p = createPolicy(SHAPE, rng(7));
79 const g = zeroGrads(SHAPE);
80 g.b1.fill(1);
81 const before = (p.b1[0] as number);
82 const adam = createAdam(p, DEFAULT_HYPER);
83 const norm = applyGrads(p, g, adam, 1);
84 expect(norm).toBeCloseTo(Math.sqrt(SHAPE.hidden), 5);
85 // Adam's first step is about one learning rate, whatever the gradient size.
86 expect(before - (p.b1[0] as number)).toBeCloseTo(DEFAULT_HYPER.learningRate, 5);
87 });
static/app.dee3add82f.js → static/app.522be2f0cc.js renamed +4 −4

Diff not shown — 141,401 bytes of diff (limit 100,000). +4 −4

View file
static/example.004d95abfe.js added +1

Diff not shown — a line 8,748 characters wide (limit 2,000). +1

View file
static/example.c0d730d304.js deleted −1

Diff not shown — a line 8,748 characters wide (limit 2,000). −1

View file
static/range.79e3fb073e.js deleted −58

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

View file
static/range.7a769ecf3e.js added +58

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

View file
static/range.f0416248f9.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs