Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The bins the shipped weights were trained on get the test they never had: src/client/action.ts #201
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/108-the-bins-the-shipped-weights-were-traine → main 3 commits fork
6 files
changed +267
−219
src/client/action.test.ts +206 −43
| @@ -4,57 +4,220 @@ | ||
| 4 | 4 | * what any of them says. These tests moved out of `policy.test.ts` when the |
| 5 | 5 | * network itself went behind the door in #106 — the arithmetic went with it as |
| 6 | 6 | * `src/engine/net.test.ts`, and the bins stayed here, where the bins are. |
| 7 | * | |
| 8 | * The bins are a contract with weights that were trained against them, so the | |
| 9 | * expectations below are written as literals rather than as `decode`'s own | |
| 10 | * arithmetic said twice: a table of what each index means, walked over the | |
| 11 | * whole 9·7·3·3·2·3. A test that recomputed the index would agree with any | |
| 12 | * future mistake made the same way, and the first sign of one is a bot standing | |
| 13 | * still on the map. | |
| 7 | 14 | */ |
| 8 | 15 | |
| 9 | 16 | import { expect, test } from "bun:test"; |
| 10 | import { AXIS_BINS, decode, PITCH_BINS, toInput, YAW_BINS } from "./action.ts"; | |
| 17 | import { | |
| 18 | ACTION_SIZE, AXIS_BINS, decode, HEADS, PITCH_BINS, STILL, toInput, YAW_BINS, type Intent, | |
| 19 | } from "./action.ts"; | |
| 20 | import { forward, greedy, newActivations, rng, sample, unpack } from "../engine/index.ts"; | |
| 21 | import { OBS_SIZE } from "./observe.ts"; | |
| 22 | import { PACKED } from "./weights.ts"; | |
| 23 | import { NO_INPUT } from "./world.ts"; | |
| 11 | 24 | |
| 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); | |
| 25 | /** The bins, written out again by hand. If these two lists ever disagree, one of them moved. */ | |
| 26 | const YAW = [-720, -240, -80, -25, 0, 25, 80, 240, 720]; | |
| 27 | const PITCH = [-240, -80, -25, 0, 25, 80, 240]; | |
| 28 | const AXIS = [-1, 0, 1]; | |
| 29 | /** The posture head: index 0 is neither, 1 is walking, 2 is ducking. */ | |
| 30 | const POSTURE = [ | |
| 31 | { walk: false, duck: false }, | |
| 32 | { walk: true, duck: false }, | |
| 33 | { walk: false, duck: true }, | |
| 34 | ]; | |
| 22 | 35 | |
| 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"); | |
| 36 | test("the bins are the ones the shipped policy was trained on", () => { | |
| 37 | // Spread because the bins are a readonly tuple and these lists are plain. | |
| 38 | expect([...YAW_BINS]).toEqual(YAW); | |
| 39 | expect([...PITCH_BINS]).toEqual(PITCH); | |
| 40 | expect([...AXIS_BINS]).toEqual(AXIS); | |
| 41 | expect(HEADS).toEqual([9, 7, 3, 3, 2, 3]); | |
| 42 | expect(ACTION_SIZE).toBe(6); | |
| 43 | expect(ACTION_SIZE).toBe(HEADS.length); | |
| 31 | 44 | }); |
| 32 | 45 | |
| 33 | test("a bin the head does not have is clamped to the nearest one it does", () => { | |
| 34 | // A sample of the right shape only ever names its own bins. One that is | |
| 35 | // short, or that came from a net trained against different heads, names a bin | |
| 36 | // that is not there — and the nearest bin is a bot that turns too far, while | |
| 37 | // an unchecked read is a view angle of NaN that never comes back. | |
| 38 | const past = decode(new Int32Array([YAW_BINS.length + 3, -2, 99, -1, 0, 0])); | |
| 39 | expect(past.yawRate).toBe(YAW_BINS[YAW_BINS.length - 1]!); | |
| 40 | expect(past.pitchRate).toBe(PITCH_BINS[0]!); | |
| 41 | expect(past.forward).toBe(AXIS_BINS[AXIS_BINS.length - 1]!); | |
| 42 | expect(past.side).toBe(AXIS_BINS[0]!); | |
| 43 | expect(Number.isNaN(past.yawRate)).toBe(false); | |
| 44 | ||
| 45 | // A sample that stops short of the six heads is the same question asked of a | |
| 46 | // missing index, and it answers with the first bin rather than undefined. | |
| 47 | const short = decode(new Int32Array([1])); | |
| 48 | expect(short.pitchRate).toBe(PITCH_BINS[0]!); | |
| 49 | expect(short.duck).toBe(false); | |
| 46 | /** | |
| 47 | * The one failure that could pass every other test in this repository: weights | |
| 48 | * quantised against a different action or observation space than the one the | |
| 49 | * game hands them. `weights.test.ts` asks the same of the unpacked policy; this | |
| 50 | * asks it of the shape the file declares, which is what `unpack` reads. | |
| 51 | */ | |
| 52 | test("the shipped weights and the spaces they were trained on agree", () => { | |
| 53 | expect(PACKED.shape.heads).toEqual(HEADS); | |
| 54 | expect(PACKED.shape.obs).toBe(OBS_SIZE); | |
| 55 | expect(unpack(PACKED).shape.heads).toEqual(HEADS); | |
| 50 | 56 | }); |
| 51 | 57 | |
| 52 | test("the middle bin of every axis is standing still", () => { | |
| 53 | const middle = new Int32Array([ | |
| 54 | YAW_BINS.indexOf(0), PITCH_BINS.indexOf(0), AXIS_BINS.indexOf(0), AXIS_BINS.indexOf(0), 0, 0, | |
| 55 | ]); | |
| 56 | const intent = decode(middle); | |
| 57 | expect(intent).toEqual({ | |
| 58 | yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false, | |
| 58 | test("every index of every head decodes to the bin it names", () => { | |
| 59 | const actions = new Int32Array(ACTION_SIZE); | |
| 60 | let seen = 0; | |
| 61 | for (let y = 0; y < YAW.length; y++) { | |
| 62 | for (let p = 0; p < PITCH.length; p++) { | |
| 63 | for (let f = 0; f < AXIS.length; f++) { | |
| 64 | for (let s = 0; s < AXIS.length; s++) { | |
| 65 | for (let b = 0; b < 2; b++) { | |
| 66 | for (let t = 0; t < POSTURE.length; t++) { | |
| 67 | actions.set([y, p, f, s, b, t]); | |
| 68 | expect(decode(actions)).toEqual({ | |
| 69 | yawRate: YAW[y]!, | |
| 70 | pitchRate: PITCH[p]!, | |
| 71 | forward: AXIS[f]!, | |
| 72 | side: AXIS[s]!, | |
| 73 | fire: b === 1, | |
| 74 | walk: POSTURE[t]!.walk, | |
| 75 | duck: POSTURE[t]!.duck, | |
| 76 | }); | |
| 77 | seen += 1; | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | expect(seen).toBe(9 * 7 * 3 * 3 * 2 * 3); | |
| 85 | expect(seen).toBe(3402); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a head is read from its own index and not its neighbour's", () => { | |
| 89 | // Six heads whose indices are all different, so reading one along shows. | |
| 90 | const actions = new Int32Array([1, 2, 0, 2, 1, 2]); | |
| 91 | expect(decode(actions)).toEqual({ | |
| 92 | yawRate: -240, pitchRate: -25, forward: -1, side: 1, fire: true, walk: false, duck: true, | |
| 59 | 93 | }); |
| 60 | 94 | }); |
| 95 | ||
| 96 | /** | |
| 97 | * An index a head does not have is a sample from somewhere else — short, or | |
| 98 | * from a net trained against different heads. `decode` clamps it to the nearest | |
| 99 | * bin, because the nearest bin is a bot that turns too far while `undefined` is | |
| 100 | * a view angle of NaN that never comes back. The expectations are the ends of | |
| 101 | * the tables above, written out. | |
| 102 | */ | |
| 103 | test("a bin the head does not have is clamped to the nearest one it does", () => { | |
| 104 | const past = decode(new Int32Array([YAW.length + 3, -2, 99, -1, 0, 0])); | |
| 105 | expect(past).toEqual({ | |
| 106 | yawRate: 720, // the last yaw bin, not undefined | |
| 107 | pitchRate: -240, // the first pitch bin: a negative index clamps to 0 | |
| 108 | forward: 1, | |
| 109 | side: -1, | |
| 110 | fire: false, | |
| 111 | walk: false, | |
| 112 | duck: false, | |
| 113 | }); | |
| 114 | for (const v of [past.yawRate, past.pitchRate, past.forward, past.side]) | |
| 115 | expect(Number.isNaN(v)).toBe(false); | |
| 116 | ||
| 117 | // A sample that stops short of the six heads asks the same question of a | |
| 118 | // missing index, and answers with the first bin rather than undefined. | |
| 119 | expect(decode(new Int32Array([1]))).toEqual({ | |
| 120 | yawRate: -240, pitchRate: -240, forward: -1, side: -1, fire: false, walk: false, duck: false, | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | test("`at` reads one sample out of a buffer of many", () => { | |
| 125 | const buffer = new Int32Array([ | |
| 126 | 8, 0, 2, 0, 0, 1, // a sample at 0: hard right, nose down, forward, walking | |
| 127 | 0, 6, 0, 2, 1, 2, // a sample at 6: hard left, nose up, back, right, firing, ducking | |
| 128 | ]); | |
| 129 | expect(decode(buffer, 0)).toEqual({ | |
| 130 | yawRate: 720, pitchRate: -240, forward: 1, side: -1, fire: false, walk: true, duck: false, | |
| 131 | }); | |
| 132 | expect(decode(buffer, 6)).toEqual({ | |
| 133 | yawRate: -720, pitchRate: 240, forward: -1, side: 1, fire: true, walk: false, duck: true, | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the middle bin of every axis is standing still, and that is STILL", () => { | |
| 138 | const middle = new Int32Array([4, 3, 1, 1, 0, 0]); | |
| 139 | expect(decode(middle)).toEqual({ | |
| 140 | yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false, | |
| 141 | }); | |
| 142 | expect(STILL).toEqual({ | |
| 143 | yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false, | |
| 144 | }); | |
| 145 | expect(decode(middle)).toEqual(STILL); | |
| 146 | }); | |
| 147 | ||
| 148 | const INTENT: Intent = { | |
| 149 | yawRate: 240, pitchRate: -80, forward: 1, side: -1, fire: true, walk: true, duck: false, | |
| 150 | }; | |
| 151 | ||
| 152 | test("an intent becomes an input the game can apply, field by field", () => { | |
| 153 | expect(toInput(INTENT, false)).toEqual({ | |
| 154 | forward: 1, | |
| 155 | side: -1, | |
| 156 | jump: false, | |
| 157 | duck: false, | |
| 158 | walk: true, | |
| 159 | fire: true, | |
| 160 | reload: false, | |
| 161 | use: false, | |
| 162 | scope: false, | |
| 163 | throwing: null, | |
| 164 | switchTo: null, | |
| 165 | }); | |
| 166 | // Turning is not an Input: the caller moves the view, the way a mouse does. | |
| 167 | // `toEqual` above already refuses an extra field; this names the one meant. | |
| 168 | expect(toInput(INTENT, false)).not.toHaveProperty("yawRate"); | |
| 169 | expect(toInput(INTENT, false)).not.toHaveProperty("pitchRate"); | |
| 170 | }); | |
| 171 | ||
| 172 | test("reload and a weapon switch are carried, and the other posture too", () => { | |
| 173 | const ducking: Intent = { ...INTENT, walk: false, duck: true, fire: false, forward: -1, side: 1 }; | |
| 174 | expect(toInput(ducking, true, "ak47")).toEqual({ | |
| 175 | forward: -1, | |
| 176 | side: 1, | |
| 177 | jump: false, | |
| 178 | duck: true, | |
| 179 | walk: false, | |
| 180 | fire: false, | |
| 181 | reload: true, | |
| 182 | use: false, | |
| 183 | scope: false, | |
| 184 | throwing: null, | |
| 185 | switchTo: "ak47", | |
| 186 | }); | |
| 187 | }); | |
| 188 | ||
| 189 | test("a still intent is the world's own no-input", () => { | |
| 190 | expect(toInput(STILL, false)).toEqual(NO_INPUT); | |
| 191 | }); | |
| 192 | ||
| 193 | /** | |
| 194 | * And the end to end: whatever the shipped weights say about an observation, | |
| 195 | * greedily or sampled, is an intent the game can act on. | |
| 196 | */ | |
| 197 | test("the shipped policy decodes to a valid intent on a seeded sweep", () => { | |
| 198 | const net = unpack(PACKED); | |
| 199 | const act = newActivations(net.shape); | |
| 200 | const actions = new Int32Array(ACTION_SIZE); | |
| 201 | const obs = new Float32Array(OBS_SIZE); | |
| 202 | const random = rng(20108); | |
| 203 | const check = (intent: Intent): void => { | |
| 204 | expect(YAW).toContain(intent.yawRate); | |
| 205 | expect(PITCH).toContain(intent.pitchRate); | |
| 206 | expect(AXIS).toContain(intent.forward); | |
| 207 | expect(AXIS).toContain(intent.side); | |
| 208 | expect(typeof intent.fire).toBe("boolean"); | |
| 209 | // Walking and ducking come from one head, so a bot never asks for both. | |
| 210 | expect(intent.walk && intent.duck).toBe(false); | |
| 211 | for (const v of [intent.yawRate, intent.pitchRate, intent.forward, intent.side]) | |
| 212 | expect(Number.isNaN(v)).toBe(false); | |
| 213 | }; | |
| 214 | for (let i = 0; i < 200; i++) { | |
| 215 | for (let j = 0; j < OBS_SIZE; j++) obs[j] = random() * 2 - 1; // the trained range | |
| 216 | forward(net, obs, act); | |
| 217 | for (const v of act.logits) expect(Number.isFinite(v)).toBe(true); | |
| 218 | greedy(net.shape, act.logits, actions); | |
| 219 | check(decode(actions)); | |
| 220 | sample(net.shape, act.logits, random, actions); | |
| 221 | check(decode(actions)); | |
| 222 | } | |
| 223 | }); |
src/client/main.test.ts +3 −2
| @@ -750,9 +750,10 @@ test("a rematch is a new row: the id is the match, the posted key is this playin | ||
| 750 | 750 | test("the match being played is on the page: the id, the map, the link and the pause line", () => { |
| 751 | 751 | const stage = pasted(PASTED, { map: "generated" }); |
| 752 | 752 | const level = worldOf(stage)?.level; |
| 753 | expect(level?.name).toBe(nameOf(seedFrom(PASTED))); | |
| 753 | const mapName = nameOf(seedFrom(PASTED)); | |
| 754 | expect(level?.name).toBe(mapName); | |
| 754 | 755 | expect(stage.by("playing").textContent).toBe(PASTED); |
| 755 | expect(stage.by("mapname").textContent).toBe(level?.name); | |
| 756 | expect(stage.by("mapname").textContent).toBe(mapName); | |
| 756 | 757 | // A real href, to this match on this map: following it is the rematch. |
| 757 | 758 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`); |
| 758 | 759 | // The pause panel says the same thing in words, because on a full screen |
static/app.4933b16c8f.js deleted −58
Diff not shown — a line 73,128 characters wide (limit 2,000). −58
View filestatic/app.8342c8f923.js deleted −58
Diff not shown — a line 73,223 characters wide (limit 2,000). −58
View filestatic/app.bb7df12ff4.js deleted −58
Diff not shown — a line 73,851 characters wide (limit 2,000). −58
View filestatic/app.e24e7af2ef.js added +58
Diff not shown — a line 73,945 characters wide (limit 2,000). +58
View file
new above
offline