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

Merged opened this

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

Merged

torque/cs-engine:torque/108-the-bins-the-shipped-weights-were-traine → main 3 commits fork

  • Merged. torque/108-the-bins-the-shipped-weights-were-traine was merged into main as cb60a5d.

Review

Sign in to review this change.
6 files changed +267 −219
src/client/action.test.ts +206 −43
@@ -4,57 +4,220 @@
44 * what any of them says. These tests moved out of `policy.test.ts` when the
55 * network itself went behind the door in #106 — the arithmetic went with it as
66 * `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.
714 */
815
916 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";
1124
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 ];
2235
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);
3144 });
3245
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);
5056 });
5157
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,
5993 });
6094 });
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
750750 test("the match being played is on the page: the id, the map, the link and the pause line", () => {
751751 const stage = pasted(PASTED, { map: "generated" });
752752 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);
754755 expect(stage.by("playing").textContent).toBe(PASTED);
755 expect(stage.by("mapname").textContent).toBe(level?.name);
756 expect(stage.by("mapname").textContent).toBe(mapName);
756757 // A real href, to this match on this map: following it is the rematch.
757758 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`);
758759 // 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 file
static/app.8342c8f923.js deleted −58

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

View file
static/app.bb7df12ff4.js deleted −58

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

View file
static/app.e24e7af2ef.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs