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

forked from trevato/cs · proposes

A seeded round is pinned: a golden digest proves an engine extraction changed nothing #22

Merged opened this

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

Merged

torque/cs-engine:torque/19-a-seeded-round-is-pinned-a-golden-digest → main 1 commit fork

  • Merged. torque/19-a-seeded-round-is-pinned-a-golden-digest was merged into main as 424af13.

Review

Sign in to review this change.
3 files changed +457 −0
README.md +10
@@ -75,6 +75,16 @@ the rest.
7575 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
7676 the algorithm, with its backward pass checked against finite differences.
7777 `scripts/train.ts` runs them and writes `src/client/weights.ts`.
78 - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel
79 in the arena, each stepped through one fixed script of inputs and hashed tick
80 by tick (positions, velocities, view angles, health, armour, duck and
81 magazine, plus the phase, the timer and the score, all quantised to 1e-4 so a
82 CPU's last bit cannot fail the test), against digests committed in
83 `src/client/replay.test.ts`. The rule is that an extraction which moves a
84 digest has changed behaviour and is not an extraction, while a deliberate
85 change to the simulation updates the digest in the same pull and says what
86 moved; a failure names the first second at which the two runs part and prints
87 the constants to paste.
7888 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
7989 schema and its queries, `src/result.ts` validates what a browser may claim.
8090 - `src/pages/shell.ts` — the document every game is served inside, and nothing
src/client/replay.test.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * The golden digests: what this simulation does, pinned.
3 *
4 * Two runs, both seeded, both stepped through the same script in
5 * `replay.ts`: the whole game — ten players, the match rules, the bomb, the
6 * bots and their policy — and the duel in the training arena, so an
7 * extraction that moves the environment PPO learned in is caught as loudly as
8 * one that moves the game.
9 *
10 * If one of these fails, read the message: it names the first second of play
11 * at which the run and the pin part company, and prints the constants to
12 * paste in. Paste them only when the change to the simulation was deliberate,
13 * and say in the pull what moved and why. An extraction that changes a digest
14 * is not an extraction.
15 *
16 * Measured: the two pinned runs together are 0.52 s (the round 0.46 s — ten
17 * bots running a policy every fourth tick for forty seconds is the work — and
18 * the duel 0.06 s), and this whole file, short runs and all, is 0.66 s of the
19 * suite's 3.3 s.
20 */
21
22 import { expect, test } from "bun:test";
23 import { SHORT } from "./match.ts";
24 import {
25 CHECKPOINT_CHARS, compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND,
26 withoutEntropy, type Pin, type Run,
27 } from "./replay.ts";
28 import { createWorld, type Player } from "./world.ts";
29 import { createArena, reset, stepInput } from "../rl/arena.ts";
30
31 const SECONDS = 40;
32 const TICKS = SECONDS * TICKS_PER_SECOND;
33
34 /**
35 * Forty seconds of a five-a-side round on de_forge, seed 7. Computed on
36 * 06a3c81 ("One page shell serves both games"), the commit this branch left
37 * main at, before anything in this pull touched the tree.
38 */
39 const ROUND = "6e2532e264dedc1b0e627305cc8dc6adc7c9331467ba99ed85b8dbd00c5288a0";
40 const ROUND_SECONDS = [
41 "9e65233c0d48", "aa3a9181fd4e", "7c4010b39595", "f1f17242e53a",
42 "f60445aeab1c", "fe6db21f4254", "6a9cb307907d", "0d04a4981ebb",
43 "4f5b67b31f3a", "1143cc644fa5", "233dfe9b86ad", "a484e4d8c88a",
44 "a8def47d1078", "edc047a03760", "041e88d4a21a", "7d1355c2c4e7",
45 "5612f8126e48", "da03b727e727", "f11d1891e25d", "dd54b431fcf4",
46 "fdd3cb238ea2", "44a94744ba5f", "51aafcbd7178", "87b3d8a4f92c",
47 "1c7e703d3c31", "022fc11cab00", "b7da2d1b042c", "2aecf3498845",
48 "65a47f51d197", "793d4cc2982f", "f7e5296e6308", "cefff31fc624",
49 "ef1a5a248bc6", "d655548a346b", "fe852048d7cd", "d35a992a9295",
50 "83ddecae01bf", "adb7eefdf1f4", "fd307b97235e", "0c623d45e867",
51 ];
52
53 /**
54 * Forty seconds of duels in `src/rl/arena.ts`, seed 4242, against the
55 * scripted opponent, resetting each time one of them dies. Computed on the
56 * same commit, 06a3c81.
57 */
58 const DUEL = "5cdb7419bdd67eff8e59436620d6bf3c65169d3f0477114d16b634c6b1bc79b6";
59 const DUEL_SECONDS = [
60 "4b602f465cc3", "55bd03b39724", "7ed7a3e9852c", "384c7656b58c",
61 "d5a4fed56e37", "0c14ed3d20b0", "fb4abcb5a49a", "5cd40562c219",
62 "21d1d1268ed9", "8a09b3475552", "8926d65beaf0", "60f7da994999",
63 "4c87374025da", "7700baee5ede", "fe59251327c2", "85c8b4f5851d",
64 "9ddcbb06870b", "ef4346bb917d", "701b38b7cc85", "7b0310352ead",
65 "9a7977b11907", "368b58be4235", "e7a36f9aa08e", "214a8b8bff24",
66 "0d20594c6069", "ba43b863d3cc", "3cfebac1c436", "c5f40350e06c",
67 "a13f1ee2bf59", "657dd44ce153", "79e2aee250d0", "67c57043bc04",
68 "2fa20867e8c7", "f25307c5968c", "e36ece4c4588", "14525727cbb2",
69 "3e8043af606f", "5b20a5d42233", "955040d49339", "d2053b7c791c",
70 ];
71
72 const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS };
73 const DUEL_PIN: Pin = { label: "arena duel", constant: "DUEL", digest: DUEL, seconds: DUEL_SECONDS };
74
75 /** The round, exactly as the pin was taken. */
76 const roundRun = (): Run => withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), TICKS));
77
78 /**
79 * The duel, stepped the arena's own way: the learner takes the script, the
80 * opponent is the scripted bot the policy trains against, and an episode that
81 * ends is reset, which is where the arena draws every number it draws.
82 */
83 const duelRun = (): Run =>
84 withoutEntropy(() => {
85 const arena = createArena(4242);
86 const next = reader();
87 return fold(arena.world, TICKS, (tick) => {
88 if (stepInput(arena, next(tick, arena.world, arena.learner), TICK).done) reset(arena);
89 });
90 });
91
92 const must = (run: Run, pin: Pin): void => {
93 const complaint = compare(run, pin);
94 if (complaint !== null) throw new Error(complaint);
95 };
96
97 test("a seeded ten-bot round plays exactly as it played", () => {
98 const run = roundRun();
99 must(run, ROUND_PIN);
100 expect(run.digest).toBe(ROUND);
101 expect(run.ticks).toBe(TICKS);
102 expect(run.seconds).toEqual(ROUND_SECONDS);
103 });
104
105 test("a seeded duel in the training arena plays exactly as it played", () => {
106 const run = duelRun();
107 must(run, DUEL_PIN);
108 expect(run.digest).toBe(DUEL);
109 expect(run.seconds.length).toBe(SECONDS);
110 });
111
112 test("the same seed is the same run, and another seed is another run", () => {
113 const four = 4 * TICKS_PER_SECOND;
114 const a = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four));
115 const b = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four));
116 const other = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 8), four));
117 expect(a.digest).toBe(b.digest);
118 expect(a.digest).not.toBe(other.digest);
119 // The first four seconds of the long run are the first four seconds, too.
120 expect(a.seconds).toEqual(ROUND_SECONDS.slice(0, 4));
121 });
122
123 test("a move the game could not feel leaves the digest alone; one it could moves it", () => {
124 const world = createWorld(SHORT, "me", "ct", 5, 7);
125 const before = snapshot(world);
126 world.me.pos[0] += 2e-6; // a fiftieth of the quantum: libm noise, not behaviour
127 expect(snapshot(world)).toBe(before);
128 world.me.pos[0] += 1e-3; // ten quanta: still invisible to a person, caught anyway
129 expect(snapshot(world)).not.toBe(before);
130 });
131
132 test("a nudged simulation is a different run from the first second on", () => {
133 const four = 4 * TICKS_PER_SECOND;
134 const nudged = withoutEntropy(() => {
135 const world = createWorld(SHORT, "me", "ct", 5, 7);
136 (world.players[3] as Player).pos[1] += 1; // one unit, on one bot, before the first tick
137 return digest(world, four);
138 });
139 expect(nudged.seconds[0]).not.toBe(ROUND_SECONDS[0]);
140 const complaint = compare(nudged, { ...ROUND_PIN, seconds: ROUND_SECONDS.slice(0, 4) }) as string;
141 expect(complaint).toContain("second 1 of 4");
142 });
143
144 test("a mismatch says which second went another way, and what to paste", () => {
145 const pinned: Pin = {
146 label: "toy", constant: "TOY", digest: "aaaa",
147 seconds: ["one0000000000", "two0000000000", "three000000000"],
148 };
149 const run: Run = {
150 digest: "bbbb", ticks: 3 * TICKS_PER_SECOND,
151 seconds: ["one0000000000", "changed000000", "three000000000"],
152 };
153 const complaint = compare(run, pinned) as string;
154 expect(complaint).toContain("second 2 of 3");
155 expect(complaint).toContain("ticks 64..127");
156 expect(complaint).toContain("1 of 3 seconds differ");
157 expect(complaint).toContain("pinned two0000000000, ran changed000000");
158 expect(complaint).toContain('const TOY = "bbbb";');
159 expect(complaint).toContain('"changed000000"');
160 expect(compare({ ...run, digest: pinned.digest, seconds: [...pinned.seconds] }, pinned)).toBe(null);
161 });
162
163 test("the run is seeded: reaching for entropy or the clock throws, and is put back", () => {
164 const random = Math.random;
165 const now = Date.now;
166 expect(() => withoutEntropy(() => Math.random())).toThrow(/Math\.random/);
167 expect(() => withoutEntropy(() => Date.now())).toThrow(/Date\.now/);
168 expect(() => withoutEntropy(() => performance.now())).toThrow(/performance\.now/);
169 expect(Math.random).toBe(random);
170 expect(Date.now).toBe(now);
171 expect(Math.random()).toBeGreaterThanOrEqual(0);
172 });
173
174 test("a checkpoint is a short digest, one a second, and the whole run is a full one", () => {
175 expect(ROUND_SECONDS.length).toBe(SECONDS);
176 expect(DUEL_SECONDS.length).toBe(SECONDS);
177 for (const second of [...ROUND_SECONDS, ...DUEL_SECONDS]) {
178 expect(second.length).toBe(CHECKPOINT_CHARS);
179 expect(second).toMatch(/^[0-9a-f]+$/);
180 }
181 expect(ROUND.length).toBe(64);
182 expect(DUEL.length).toBe(64);
183 expect(new Set(ROUND_SECONDS).size).toBe(SECONDS);
184 });
src/client/replay.ts added +263
@@ -0,0 +1,263 @@
1 /**
2 * A replay: one seeded round, one fixed script of inputs, one sha256.
3 *
4 * Every refactor in this repository ends with the claim "the game plays
5 * exactly as before", and nobody can check that by eye. This module makes it
6 * a number. Step a seeded world through a script nobody changes, fold the
7 * simulation's state into a hash after every tick, and compare the result with
8 * a digest committed in `replay.test.ts`. An extraction that moves the digest
9 * moved behaviour; a deliberate change to the simulation moves it on purpose
10 * and updates the constant in the same pull.
11 *
12 * What is hashed is the simulation and nothing else — PLAYER_FIELDS and
13 * MATCH_FIELDS below are the whole list. Effects, the kill feed and the
14 * messages are presentation: they would make the digest fragile and would
15 * catch nothing the state does not already catch.
16 */
17
18 import { buy, NO_INPUT, update, type BuyItem, type Input, type Player, type World } from "./world.ts";
19 import type { MatchState, Phase } from "./match.ts";
20
21 export const TICK = 1 / 64;
22 export const TICKS_PER_SECOND = 64;
23
24 // --- what is hashed -------------------------------------------------------
25
26 /**
27 * Floats are quantised before they are hashed: every value is multiplied by
28 * 1e4, rounded to an integer and written in decimal.
29 *
30 * Why, and not just what: the simulation runs on Math.hypot, Math.atan2,
31 * Math.sin and Math.exp, and those are library functions, not IEEE-754
32 * operations — the standard does not pin their last bit, so two CPUs (or two
33 * builds of the same libm) may disagree by an ulp on a trig call. Hashing the
34 * raw doubles would make this test fail on somebody else's laptop for a reason
35 * that is not a bug. A ten-thousandth of a Source unit is about 2.5 microns of
36 * map and a ten-thousandth of a degree of aim: far below anything the game can
37 * feel, and far above the noise. A real behaviour change moves a position by
38 * whole units within a tick or two, so it still shows up.
39 */
40 export const QUANTUM = 1e-4;
41 const SCALE = 1 / QUANTUM;
42
43 /** One number, ready to hash. Non-finite values are written as themselves. */
44 const q = (x: number): string => (Number.isFinite(x) ? String(Math.round(x * SCALE)) : `!${x}`);
45
46 const PHASES: Phase[] = ["freeze", "live", "planted", "over", "match_over"];
47
48 /** Everything hashed about a player, in the order it is written. */
49 export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [
50 { name: "pos.x", of: (p) => p.pos[0] },
51 { name: "pos.y", of: (p) => p.pos[1] },
52 { name: "pos.z", of: (p) => p.pos[2] },
53 { name: "vel.x", of: (p) => p.vel[0] },
54 { name: "vel.y", of: (p) => p.vel[1] },
55 { name: "vel.z", of: (p) => p.vel[2] },
56 { name: "yaw", of: (p) => p.yaw },
57 { name: "pitch", of: (p) => p.pitch },
58 { name: "health", of: (p) => p.health },
59 { name: "armor", of: (p) => p.armor },
60 { name: "duckAmount", of: (p) => p.duckAmount },
61 // -1 for a weapon that has no magazine (the knife), so a switch still shows.
62 { name: "mag", of: (p) => p.ammo[p.weapon]?.mag ?? -1 },
63 ];
64
65 /** Everything hashed about the match. */
66 export const MATCH_FIELDS: { name: string; of: (m: MatchState) => number }[] = [
67 { name: "phase", of: (m) => PHASES.indexOf(m.phase) },
68 { name: "timer", of: (m) => m.timer },
69 { name: "score.t", of: (m) => m.score.t },
70 { name: "score.ct", of: (m) => m.score.ct },
71 ];
72
73 /** The state of one tick as the bytes that go into the hash. */
74 export function snapshot(world: World): string {
75 const parts: string[] = [];
76 for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(q(field.of(p)));
77 for (const field of MATCH_FIELDS) parts.push(q(field.of(world.match)));
78 return `${parts.join(",")}\n`;
79 }
80
81 // --- the script -----------------------------------------------------------
82
83 /**
84 * One beat of the run: from `atTick` on, this is the whole input the person is
85 * holding, exactly as a keyboard holds it until a key goes up. `buy` is the
86 * one thing a person does that is not an Input — it happens once, on that
87 * tick, and the world refuses it outside the freeze.
88 */
89 export type Beat = { atTick: number; input: Partial<Input>; buy?: BuyItem };
90
91 /**
92 * Forty seconds of somebody playing: eight seconds of freeze with a buy in
93 * it, then walking, strafing, a jump, a burst of fire, a reload, a use on the
94 * bomb site and a crouch. The ticks are round numbers so a failure at "second
95 * 12" points at a line here.
96 */
97 export const SCRIPT: Beat[] = [
98 { atTick: 0, input: {} },
99 { atTick: 64, input: {}, buy: "helmet" }, // 1000 of the 800 start money: refused, and pinned as refused
100 { atTick: 96, input: {}, buy: "armor" }, // 650: affordable, and taken
101 { atTick: 128, input: { forward: 1 } }, // frozen: the world must ignore this
102 { atTick: 512, input: { forward: 1 } }, // the round goes live
103 { atTick: 640, input: { forward: 1, side: 1 } }, // strafe out of spawn
104 { atTick: 704, input: { forward: 1, side: 1, jump: true } }, // one jump
105 { atTick: 712, input: { forward: 1, walk: true } }, // walking is quiet and slow
106 { atTick: 832, input: { forward: 1 } },
107 // A burst. The USP is semi-automatic — one shot a press — so the burst is
108 // three presses, which is what a person's finger does and what walks the
109 // spray pattern along: recoil, the seeded accuracy roll and shotIndex.
110 { atTick: 960, input: { fire: true } },
111 { atTick: 968, input: {} },
112 { atTick: 976, input: { fire: true } },
113 { atTick: 984, input: {} },
114 { atTick: 992, input: { fire: true } },
115 { atTick: 1024, input: {} },
116 { atTick: 1088, input: { reload: true } },
117 { atTick: 1096, input: { forward: 1, side: -1 } },
118 { atTick: 1280, input: { use: true } }, // plant or defuse, whichever side is standing there
119 { atTick: 1408, input: { forward: 1, duck: true } },
120 { atTick: 1536, input: { forward: 1, fire: true } }, // firing on the move: the moving-spread term
121 { atTick: 1552, input: { forward: 1 } },
122 { atTick: 1568, input: { forward: 1, fire: true } },
123 { atTick: 1600, input: { forward: -1 } },
124 { atTick: 1792, input: { side: 1, walk: true } },
125 { atTick: 1984, input: { forward: 1, jump: true } },
126 { atTick: 1992, input: { forward: 1 } },
127 { atTick: 2176, input: { fire: true, duck: true } },
128 { atTick: 2240, input: { use: true } },
129 { atTick: 2368, input: { forward: 1, side: 1 } },
130 { atTick: 2496, input: {} },
131 ];
132
133 /**
134 * A cursor over the script: hand it a tick and it returns the input held at
135 * that tick, taking any buy on the way past. The input is a fresh object each
136 * beat, never mutated, so a step cannot smuggle state into the next one.
137 */
138 export function reader(script: readonly Beat[] = SCRIPT): (tick: number, world: World, player: Player) => Input {
139 let at = 0;
140 let input: Input = { ...NO_INPUT };
141 return (tick: number, world: World, player: Player): Input => {
142 while (at < script.length && (script[at] as Beat).atTick <= tick) {
143 const beat = script[at++] as Beat;
144 input = { ...NO_INPUT, ...beat.input };
145 if (beat.buy !== undefined) buy(world, player, beat.buy);
146 }
147 return input;
148 };
149 }
150
151 // --- the run --------------------------------------------------------------
152
153 export type Run = {
154 /** sha256 of every tick of the run, hex. */
155 digest: string;
156 /** One short digest per whole second, for finding where two runs part. */
157 seconds: string[];
158 ticks: number;
159 };
160
161 /** How much of each second's sha256 is kept. 48 bits is plenty to point a finger. */
162 export const CHECKPOINT_CHARS = 12;
163
164 /**
165 * Fold `ticks` ticks of `world` into a digest, checkpointing every second.
166 * `step` advances the simulation however its environment advances it; this
167 * only reads the state afterwards. The training environment in `src/rl` steps
168 * its own way, and gets pinned by handing its stepper in — `src/client` never
169 * imports `src/rl`, and this module is not going to be the file that starts.
170 */
171 export function fold(world: World, ticks: number, step: (tick: number) => void): Run {
172 const whole = new Bun.CryptoHasher("sha256");
173 let second = new Bun.CryptoHasher("sha256");
174 const seconds: string[] = [];
175 for (let tick = 0; tick < ticks; tick++) {
176 step(tick);
177 const line = snapshot(world);
178 whole.update(line);
179 second.update(line);
180 if ((tick + 1) % TICKS_PER_SECOND === 0) {
181 seconds.push(second.digest("hex").slice(0, CHECKPOINT_CHARS));
182 second = new Bun.CryptoHasher("sha256");
183 }
184 }
185 return { digest: whole.digest("hex"), seconds, ticks };
186 }
187
188 /** The whole game: ten players, the match rules, the bomb, the bots. */
189 export function digest(world: World, ticks: number, script: readonly Beat[] = SCRIPT): Run {
190 const next = reader(script);
191 return fold(world, ticks, (tick) => update(world, TICK, next(tick, world, world.me)));
192 }
193
194 // --- comparing ------------------------------------------------------------
195
196 /** A digest committed in the repository, and what to call it when it moves. */
197 export type Pin = { label: string; constant: string; digest: string; seconds: readonly string[] };
198
199 const list = (seconds: readonly string[]): string => {
200 const lines: string[] = [];
201 for (let i = 0; i < seconds.length; i += 4)
202 lines.push(` ${seconds.slice(i, i + 4).map((s) => `"${s}"`).join(", ")},`);
203 return `[\n${lines.join("\n")}\n]`;
204 };
205
206 /**
207 * Why a run and its pin differ, or null if they do not. A digest that only
208 * says "no" is a wall: this says which second of play first went another way,
209 * how much of the run went with it, and prints the constants to paste once the
210 * change is the intended one.
211 */
212 export function compare(run: Run, pin: Pin): string | null {
213 if (run.digest === pin.digest && run.seconds.length === pin.seconds.length) return null;
214 const count = Math.max(run.seconds.length, pin.seconds.length);
215 let first = -1;
216 let differ = 0;
217 for (let i = 0; i < count; i++) {
218 if (run.seconds[i] === pin.seconds[i]) continue;
219 if (first < 0) first = i;
220 differ += 1;
221 }
222 const where =
223 first < 0
224 ? "every second matches, so the divergence is in the length of the run"
225 : `the first second that differs is second ${first + 1} of ${count} ` +
226 `(ticks ${first * TICKS_PER_SECOND}..${(first + 1) * TICKS_PER_SECOND - 1}): ` +
227 `pinned ${pin.seconds[first] ?? "(nothing)"}, ran ${run.seconds[first] ?? "(nothing)"}. ` +
228 `${differ} of ${count} seconds differ.`;
229 return (
230 `The ${pin.label} replay no longer matches the digest pinned in src/client/replay.test.ts: ${where}\n` +
231 `Look at what changed between there and here — the run hashes ${PLAYER_FIELDS.map((f) => f.name).join(", ")} ` +
232 `per player and ${MATCH_FIELDS.map((f) => f.name).join(", ")}.\n` +
233 `If this change to the simulation is deliberate, say so in the pull and paste these in:\n\n` +
234 `const ${pin.constant} = "${run.digest}";\n` +
235 `const ${pin.constant}_SECONDS = ${list(run.seconds)};\n`
236 );
237 }
238
239 // --- proving it is seeded -------------------------------------------------
240
241 /**
242 * Run `body` with every source of entropy taken away: a simulation that is
243 * seeded never asks the platform for a number or for the time, and one that
244 * does cannot be pinned at all. Everything is put back, whatever happens.
245 */
246 export function withoutEntropy<T>(body: () => T): T {
247 const refuse = (what: string) => (): never => {
248 throw new Error(`the replay reached for ${what}: a pinned run must take every number from its seed`);
249 };
250 const realRandom = Math.random;
251 const realNow = Date.now;
252 const realPerformance = performance.now;
253 Math.random = refuse("Math.random");
254 Date.now = refuse("Date.now");
255 performance.now = refuse("performance.now");
256 try {
257 return body();
258 } finally {
259 Math.random = realRandom;
260 Date.now = realNow;
261 performance.now = realPerformance;
262 }
263 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs