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

forked from trevato/cs

What the trainer writes into the repository is under test: the card the page boasts, and the flags that write it #206

Merged opened this

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

Merged

torque/cs-engine:torque/111-what-the-trainer-writes-into-the-reposit → main 2 commits fork

  • Merged. torque/111-what-the-trainer-writes-into-the-reposit was merged into main as 66c85a7.

Review

Sign in to review this change.
6 files changed +622 −65
README.md +27
@@ -963,6 +963,33 @@ now a policy learned with PPO.
963963 dequantisation when the page loads, and roughly 7,700 multiply-adds per bot
964964 per decision: nine bots deciding at 16 Hz is well under a millisecond a second.
965965
966 **The card is a measurement, not a benchmark.** The numbers on the page and on
967 the terminal's policy tab — the ticks, the minutes, the duels and both win rates
968 — are `CARD` in `src/client/weights.ts`, which `scripts/train.ts` wrote beside
969 the weights on the day it trained them, on the machine it ran on. Nothing
970 re-runs them: a push does not train, `bun test` does not train, and a card that
971 says twelve minutes says twelve minutes of that one run rather than twelve
972 minutes of yours. Retraining replaces the weights and the card together, in one
973 commit, and the page then boasts the new run's numbers because it reads that
974 file and nothing else. What the tests do instead is hold the file honest:
975 `src/client/weights.test.ts` says every field of the card is the kind of number
976 it claims to be — rates between 0 and 100, counts whole and positive, a
977 `trainedAt` that is a date and has happened, minutes and throughput that agree
978 with the ticks to a tenth, and packed bytes that decode to exactly the number of
979 parameters the shape implies — `src/pages/game.test.ts` renders the page with a
980 card nobody trained and checks that every number in the prose moved with it, and
981 `scripts/train.test.ts` checks that the generator, handed what that file
982 exports, writes that file back character for character. What the *shipped
983 weights actually do* is a separate measurement and is `src/rl/learned.test.ts`:
984 seeded duels, run on every push.
985
986 The trainer's flags are checked before it starts: `scripts/train.ts` parses
987 argv into options up front and a flag nobody knows, a count that is not a whole
988 number, or a value that never arrived ends the run with a sentence and exit 2
989 rather than twelve minutes of training on `NaN` written over the shipped
990 weights. `--out` names the one file it writes; without it, that file is
991 `src/client/weights.ts`.
992
966993 When the gun is empty, it puts it away. A bot cannot buy mid-round and there is
967994 nothing on the floor to pick up, so a gun that has spent its last magazine stays
968995 empty for the rest of the round: `nextWeapon` in `src/client/bots.ts` hands it
scripts/train.test.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * The trainer's two ends, checked as data in and data out: the flags a run is
3 * asked for, and the module a run writes. Nothing here trains — the twelve
4 * minutes between the two is not a thing a test suite should spend, and the
5 * duels that grade the shipped weights are `src/rl/learned.test.ts`'s.
6 *
7 * The test that matters is the last one: `weightsModule` handed what
8 * `src/client/weights.ts` exports today gives that file back character for
9 * character. It says at once that the generator's formatting is what is in the
10 * tree, that the file in the tree was written by this generator and not by
11 * hand, and that a retrain writes a file shaped exactly like the one it
12 * replaces. `src/client/weights.test.ts` is the other half: it holds the file
13 * that is actually in the tree to the numbers it claims.
14 */
15
16 import { expect, test } from "bun:test";
17 import { mkdtempSync, writeFileSync } from "node:fs";
18 import { tmpdir } from "node:os";
19 import { join, resolve } from "node:path";
20 import { DEFAULTS, parseOptions, USAGE, WEIGHTS, weightsModule, type Options } from "./train.ts";
21 import { CARD, PACKED } from "../src/client/weights.ts";
22
23 const SOURCE = join(import.meta.dir, "..", "src", "client", "weights.ts");
24
25 /** What `parseOptions` gives back for these flags, as a difference from the defaults. */
26 const options = (...argv: string[]): Options => parseOptions(argv);
27
28 test("no flags is the run the README documents, writing the file the game ships", () => {
29 expect(options()).toEqual(DEFAULTS);
30 expect(DEFAULTS.out).toBe(WEIGHTS);
31 expect(WEIGHTS).toBe(SOURCE);
32 // The defaults are a real run, not placeholders.
33 expect(DEFAULTS.iters).toBeGreaterThan(0);
34 expect(DEFAULTS.envs).toBeGreaterThan(0);
35 expect(DEFAULTS.maps).toBeGreaterThan(1);
36 expect(DEFAULTS.holdout).toBeGreaterThan(0);
37 expect(DEFAULTS.foe).toBe("mixed");
38 expect(DEFAULTS.bench).toBe(false);
39 expect(DEFAULTS.dry).toBe(false);
40 });
41
42 test("every flag the trainer takes lands on the option it names", () => {
43 expect(options("--bench")).toEqual({ ...DEFAULTS, bench: true });
44 expect(options("--dry")).toEqual({ ...DEFAULTS, dry: true });
45 expect(options("--iters", "300")).toEqual({ ...DEFAULTS, iters: 300 });
46 expect(options("--envs", "8", "--steps", "4")).toEqual({ ...DEFAULTS, envs: 8, steps: 4 });
47 expect(options("--maps", "1")).toEqual({ ...DEFAULTS, maps: 1 });
48 expect(options("--holdout", "3")).toEqual({ ...DEFAULTS, holdout: 3 });
49 expect(options("--foe", "scripted")).toEqual({ ...DEFAULTS, foe: "scripted" });
50 expect(options("--foe", "still")).toEqual({ ...DEFAULTS, foe: "still" });
51 // A seed is a label, not a size, so zero is one.
52 expect(options("--seed", "0")).toEqual({ ...DEFAULTS, seed: 0 });
53 // The order they are written in is not a meaning, and the last one wins.
54 expect(options("--dry", "--iters", "7")).toEqual(options("--iters", "7", "--dry"));
55 expect(options("--iters", "7", "--iters", "9").iters).toBe(9);
56 });
57
58 test("a count that is not a count is a sentence and not a twelve-minute run on NaN", () => {
59 expect(() => options("--iters", "abc")).toThrow(`--iters wants a whole number greater than 0, and got "abc"`);
60 expect(() => options("--iters", "NaN")).toThrow("--iters wants a whole number");
61 expect(() => options("--envs", "-4")).toThrow("--envs wants a whole number");
62 expect(() => options("--envs", "0")).toThrow("--envs wants a whole number");
63 expect(() => options("--steps", "2.5")).toThrow("--steps wants a whole number");
64 expect(() => options("--maps", "")).toThrow("--maps wants a whole number");
65 expect(() => options("--maps", "1e400")).toThrow("--maps wants a whole number");
66 // A seed of 0 is fine; a seed below it is not a seed.
67 expect(() => options("--seed", "-1")).toThrow("--seed wants a whole number of 0 or more");
68 });
69
70 test("a flag with nothing after it is refused rather than swallowing the next flag", () => {
71 expect(() => options("--iters")).toThrow("--iters needs a value");
72 expect(() => options("--iters", "--dry")).toThrow("--iters needs a value");
73 expect(() => options("--foe")).toThrow("--foe needs a value");
74 expect(() => options("--out")).toThrow("--out needs a value");
75 expect(() => options("--out", " ")).toThrow("--out cannot be empty");
76 });
77
78 test("a flag nobody knows, and a word that is not a flag, stop the run and say so", () => {
79 const unknown = (): Options => options("--iter", "300");
80 expect(unknown).toThrow("unknown flag --iter");
81 // The complaint lists what there is, so the typo is one line from its fix.
82 expect(unknown).toThrow("--iters");
83 expect(() => options("300")).toThrow(`"300" is not a flag`);
84 expect(() => options("--foe", "friendly")).toThrow(`--foe is one of mixed, still, scripted, and got "friendly"`);
85 expect(USAGE).toContain("bun run scripts/train.ts");
86 for (const flag of ["--bench", "--dry", "--envs", "--foe", "--holdout", "--iters", "--maps", "--out", "--seed", "--steps"])
87 expect(USAGE).toContain(flag);
88 });
89
90 test("--out is the one path a run writes, resolved against where it was run", () => {
91 expect(options("--out", "/tmp/weights.ts").out).toBe("/tmp/weights.ts");
92 expect(options("--out", "elsewhere/w.ts").out).toBe(resolve("elsewhere/w.ts"));
93 expect(options("--out", "/tmp/w.ts").out).not.toBe(WEIGHTS);
94 });
95
96 test("the generated module says not to edit it, and says it first", () => {
97 const text = weightsModule(PACKED, CARD);
98 expect(text.startsWith("/**")).toBe(true);
99 expect(text).toContain("written by scripts/train.ts. Do not edit by hand.");
100 expect(text.indexOf("Do not edit by hand")).toBeLessThan(text.indexOf("export const CARD"));
101 expect(text.endsWith("\n")).toBe(true);
102 });
103
104 test("the same card and the same weights write the same text, every time", () => {
105 expect(weightsModule(PACKED, CARD)).toBe(weightsModule(PACKED, CARD));
106 // And a different card is a different file in exactly one place: its numbers.
107 const other = weightsModule(PACKED, { ...CARD, winRate: 3 });
108 expect(other).not.toBe(weightsModule(PACKED, CARD));
109 expect(other).toContain(`"winRate": 3`);
110 expect(other.replace(`"winRate": 3`, `"winRate": ${CARD.winRate}`)).toBe(weightsModule(PACKED, CARD));
111 });
112
113 test("what it emits parses, and exports what src/client/weights.ts exports today", async () => {
114 // Transpiled rather than imported as TypeScript: the only import in the
115 // generated module is `import type`, which erases, so what is left is a
116 // module with no imports at all and nothing of this repository in it.
117 const js = new Bun.Transpiler({ loader: "ts" }).transformSync(weightsModule(PACKED, CARD));
118 const file = join(mkdtempSync(join(tmpdir(), "train-")), "weights.js");
119 writeFileSync(file, js);
120 const generated = (await import(file)) as { CARD: typeof CARD; PACKED: typeof PACKED };
121 const shipped = await import("../src/client/weights.ts");
122
123 expect(Object.keys(generated).sort()).toEqual(Object.keys(shipped).sort());
124 expect(generated.CARD).toEqual(CARD);
125 expect(generated.PACKED).toEqual(PACKED);
126 // The bytes survive the round trip through the source text exactly: the
127 // weights are base64 in a string literal and one wrong character is a
128 // different policy.
129 expect(generated.PACKED.bytes).toBe(PACKED.bytes);
130 });
131
132 test("the file in the tree is this generator's output, character for character", async () => {
133 // If this fails, either somebody edited `src/client/weights.ts` by hand or
134 // the generator's formatting moved under it. Both are worth a failure: the
135 // page reads the file in the tree, and every other test here reads the
136 // generator.
137 expect(weightsModule(PACKED, CARD)).toBe(await Bun.file(SOURCE).text());
138 });
scripts/train.ts +133 −46
@@ -6,6 +6,7 @@
66 * bun run scripts/train.ts --iters 300 # train and write the weights
77 * bun run scripts/train.ts --iters 300 --dry # train without writing
88 * bun run scripts/train.ts --maps 1 # one map, the way it used to train
9 * bun run scripts/train.ts --out /tmp/w.ts # write the module somewhere else
910 *
1011 * Nothing here is imported by the server or the page: this file runs on a
1112 * machine with a few minutes to spare, and its only output is a table of
@@ -14,16 +15,22 @@
1415 * The PPO is the engine's, reached through the door like everything else it
1516 * offers; what is the shooter's is the arena it collects from, the observation
1617 * it fills, the heads it decodes and the curriculum below.
18 *
19 * The two ends of the run are plain functions, so `train.test.ts` can check
20 * both in milliseconds without training for twelve minutes: `parseOptions`
21 * (argv in, options out, a sentence for anything that is not one) and
22 * `weightsModule` (a card and packed weights in, the source text out). What is
23 * between them is the twelve minutes, and no test runs it.
1724 */
1825
1926 import { writeFileSync } from "node:fs";
20 import { join } from "node:path";
27 import { join, resolve } from "node:path";
2128 import { HEADS, decode } from "../src/client/action.ts";
2229 import { botThink, makeBrain } from "../src/client/bots.ts";
2330 import {
2431 computeAdvantages, copyPolicy, createAdam, createPolicy, createRollout, forward, greedy,
2532 newActivations, pack, rng, sample, unpack, update, DEFAULT_HYPER,
26 type Hyper, type Policy, type Shape,
33 type Hyper, type Packed, type Policy, type Shape,
2734 } from "../src/engine/index.ts";
2835 import { OBS_SIZE } from "../src/client/observe.ts";
2936 import {
@@ -33,25 +40,105 @@ import {
3340 import { holdoutLevels, trainingLevels } from "../src/rl/maps.ts";
3441 import { DE_FORGE } from "../src/client/map.ts";
3542 import type { Level } from "../src/client/level.ts";
43 import type { Card } from "../src/client/weights.ts";
3644
3745 export const SHAPE: Shape = { obs: OBS_SIZE, hidden: 64, heads: HEADS };
3846
39 const arg = (name: string, fallback: number): number => {
40 const at = process.argv.indexOf(`--${name}`);
41 if (at < 0) return fallback;
42 const value = Number(process.argv[at + 1]);
43 return Number.isFinite(value) ? value : fallback;
44 };
45 const flag = (name: string): boolean => process.argv.includes(`--${name}`);
47 // --- the boundary ---------------------------------------------------------
4648
47 const ENVS = arg("envs", 64);
48 const STEPS = arg("steps", 32);
49 const ITERS = arg("iters", 240);
50 const SEED = arg("seed", 20260914);
51 /** How many layouts a run learns on: de_forge and the rest carved from seeds. */
52 const MAPS = arg("maps", 96);
53 /** How many layouts it is graded on, none of which it trained on. */
54 const HOLDOUT = arg("holdout", 24);
49 /** Where the module lands when nobody says otherwise: the file the game ships. */
50 export const WEIGHTS = join(import.meta.dir, "..", "src", "client", "weights.ts");
51
52 /** Who a run practises against: the curriculum, or one opponent all the way. */
53 export type Foe = "mixed" | "still" | "scripted";
54
55 /** One run, as plain data. Everything the loop below reads comes from here. */
56 export type Options = {
57 /** Step the arena and print a throughput, instead of training. */
58 bench: boolean;
59 /** Train and print the table, but write no module. */
60 dry: boolean;
61 envs: number;
62 steps: number;
63 iters: number;
64 seed: number;
65 /** How many layouts a run learns on: de_forge and the rest carved from seeds. */
66 maps: number;
67 /** How many layouts it is graded on, none of which it trained on. */
68 holdout: number;
69 foe: Foe;
70 /** The one path this script writes. Absolute, resolved against the cwd. */
71 out: string;
72 };
73
74 export const DEFAULTS: Options = {
75 bench: false,
76 dry: false,
77 envs: 64,
78 steps: 32,
79 iters: 240,
80 seed: 20260914,
81 maps: 96,
82 holdout: 24,
83 foe: "mixed",
84 out: WEIGHTS,
85 };
86
87 /** The flags that take a whole number, and the smallest one each will accept. */
88 const NUMBERS = {
89 envs: 1, steps: 1, iters: 1, maps: 1, holdout: 1,
90 // A seed is a label, not a size: zero is a seed like any other.
91 seed: 0,
92 } as const;
93
94 const FOES: readonly Foe[] = ["mixed", "still", "scripted"];
95
96 const KNOWN = ["--bench", "--dry", "--foe", "--out", ...Object.keys(NUMBERS).map((k) => `--${k}`)].sort();
97
98 /** What the usage line says, printed under any complaint below. */
99 export const USAGE = `usage: bun run scripts/train.ts [${KNOWN.join("] [")}]`;
100
101 /**
102 * argv (what comes after the script name) in, options out. Anything that is
103 * not a run — a flag nobody knows, a value that is not a number, a count of
104 * zero, a missing value, a bare word — throws a sentence saying which flag and
105 * what it got, because training for twelve minutes on `NaN` and writing the
106 * result over the shipped weights is the failure this stands in front of.
107 */
108 export function parseOptions(argv: readonly string[]): Options {
109 const options: Options = { ...DEFAULTS };
110 const value = (flag: string, next: string | undefined): string => {
111 if (next === undefined || next.startsWith("--")) throw new Error(`${flag} needs a value`);
112 return next;
113 };
114 for (let i = 0; i < argv.length; i++) {
115 const flag = argv[i] as string;
116 if (!flag.startsWith("--")) throw new Error(`${JSON.stringify(flag)} is not a flag; ${USAGE}`);
117 const name = flag.slice(2);
118 if (flag === "--bench") options.bench = true;
119 else if (flag === "--dry") options.dry = true;
120 else if (flag === "--foe") {
121 const foe = value(flag, argv[++i]);
122 if (!FOES.includes(foe as Foe)) throw new Error(`--foe is one of ${FOES.join(", ")}, and got ${JSON.stringify(foe)}`);
123 options.foe = foe as Foe;
124 } else if (flag === "--out") {
125 const out = value(flag, argv[++i]);
126 if (out.trim() === "") throw new Error("--out cannot be empty");
127 options.out = resolve(out);
128 } else if (Object.hasOwn(NUMBERS, name)) {
129 const key = name as keyof typeof NUMBERS;
130 const text = value(flag, argv[++i]);
131 const n = Number(text);
132 if (!Number.isInteger(n) || n < NUMBERS[key])
133 throw new Error(
134 `${flag} wants a whole number ${NUMBERS[key] === 0 ? "of 0 or more" : "greater than 0"}, ` +
135 `and got ${JSON.stringify(text)}`,
136 );
137 options[key] = n;
138 } else throw new Error(`unknown flag ${flag}; known flags are ${KNOWN.join(" ")}`);
139 }
140 return options;
141 }
55142
56143 // --- evaluation -----------------------------------------------------------
57144
@@ -104,10 +191,10 @@ export function greedyDriver(policy: Policy): (arena: Arena) => void {
104191
105192 // --- the loop -------------------------------------------------------------
106193
107 function benchmark(): void {
108 const levels = trainingLevels(MAPS);
194 function benchmark({ maps, seed }: Options): void {
195 const levels = trainingLevels(maps);
109196 const arenas = Array.from({ length: 64 }, (_, i) =>
110 createArena(SEED + i, scriptedFoe, levels[i % levels.length] as Level));
197 createArena(seed + i, scriptedFoe, levels[i % levels.length] as Level));
111198 const obs = new Float32Array(OBS_SIZE);
112199 const policy = createPolicy(SHAPE, rng(1));
113200 const act = newActivations(SHAPE);
@@ -128,7 +215,8 @@ function benchmark(): void {
128215 console.log(`${steps} steps in ${seconds.toFixed(2)}s = ${(steps / seconds / 1000).toFixed(0)}k steps/s (observe + policy + arena)`);
129216 }
130217
131 async function train(): Promise<void> {
218 async function train(options: Options): Promise<void> {
219 const { envs: ENVS, steps: STEPS, iters: ITERS, seed: SEED, maps: MAPS, holdout: HOLDOUT } = options;
132220 const hyper: Hyper = { ...DEFAULT_HYPER, minibatch: Math.max(256, (ENVS * STEPS) / 4) };
133221 const random = rng(SEED);
134222 const policy = createPolicy(SHAPE, random);
@@ -153,10 +241,9 @@ async function train(): Promise<void> {
153241 // against the scripted bots at full skill and against itself. Handed a hard
154242 // opponent from the first step, the policy learns to hide instead of to
155243 // shoot, and never comes back from it.
156 const only = process.argv[process.argv.indexOf("--foe") + 1];
157244 const opponentFor = (e: number, progress: number): Arena["opponent"] => {
158 if (only === "still") return stillFoe;
159 if (only === "scripted") return scriptedFoe;
245 if (options.foe === "still") return stillFoe;
246 if (options.foe === "scripted") return scriptedFoe;
160247 const still = Math.max(0.1, 0.6 - progress * 0.5);
161248 const scripted = 0.3 + progress * 0.3;
162249 const u = (e % 10) / 10;
@@ -281,8 +368,9 @@ async function train(): Promise<void> {
281368 console.log(` scripted ${unseenBaseline.won}/${unseenBaseline.lost}/${unseenBaseline.draw} ` +
282369 `(${rate(unseenBaseline)}% won, ${draws(unseenBaseline)}% drawn)`);
283370
284 if (flag("dry")) return;
285 const out = join(import.meta.dir, "..", "src", "client", "weights.ts");
371 if (options.dry) return;
372 // The one write this script does, and it goes to the path the options name.
373 const out = options.out;
286374 writeFileSync(out, weightsModule(packed, {
287375 trainedAt: new Date().toISOString().slice(0, 10),
288376 decisions: envSteps,
@@ -302,24 +390,14 @@ async function train(): Promise<void> {
302390 console.log(`\nwrote ${out} (${packed.bytes.length} base64 chars)`);
303391 }
304392
305 type Card = {
306 trainedAt: string;
307 decisions: number;
308 envSteps: number;
309 updates: number;
310 stepsPerSecond: number;
311 minutes: number;
312 duels: number;
313 winRate: number;
314 baselineWinRate: number;
315 maps: number;
316 unseenMaps: number;
317 unseenWinRate: number;
318 unseenBaselineWinRate: number;
319 unseenDrawRate: number;
320 };
321
322 function weightsModule(packed: ReturnType<typeof pack>, card: Card): string {
393 /**
394 * The generated module, as text: the header that says not to edit it, the type
395 * the card is, the card this run measured and the packed weights. It is a pure
396 * function of its two arguments and formats the same way every time, so
397 * `train.test.ts` can hand it what `src/client/weights.ts` exports today and
398 * get that file back character for character.
399 */
400 export function weightsModule(packed: Packed, card: Card): string {
323401 return `/**
324402 * The trained policy, written by scripts/train.ts. Do not edit by hand.
325403 *
@@ -363,6 +441,15 @@ export const PACKED: Packed = {
363441 }
364442
365443 if (import.meta.main) {
366 if (flag("bench")) benchmark();
367 else await train();
444 let options: Options;
445 try {
446 options = parseOptions(process.argv.slice(2));
447 } catch (error) {
448 // A sentence and a non-zero exit, rather than twelve minutes on nonsense.
449 console.error(error instanceof Error ? error.message : String(error));
450 console.error(USAGE);
451 process.exit(2);
452 }
453 if (options.bench) benchmark(options);
454 else await train(options);
368455 }
src/client/weights.test.ts +160 −5
@@ -1,7 +1,8 @@
11 /**
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.
2 * The shipped weights and the card the page boasts, held to what they are.
3 * `weights.ts` is generated by `scripts/train.ts` and the engine's `unpack`
4 * turns it back into floats; this file is the pin that says the floats did not
5 * move and that the numbers beside them are a measurement rather than a typo.
56 *
67 * The digest is sha256 over every tensor's bytes in `tensorsOf` order, which is
78 * the order packing walks them in. It is here because #106 moved the network
@@ -9,13 +10,20 @@
910 * weight by one ulp would otherwise be invisible until a bot missed a shot, and
1011 * the seeded duel in `src/rl/learned.test.ts` would be the first to notice.
1112 * Retraining changes both the bytes and this digest, in one commit, on purpose.
13 *
14 * The card is checked against nonsense, not against a new run: a half-finished
15 * training run, a rebase or a hand edit can leave a `winRate` of 999 or a
16 * `trainedAt` that is not a date, and the page renders whatever is here into
17 * its first paint. Every bound below is loose enough that an honest retrain
18 * with different numbers stays green. `scripts/train.test.ts` is the other
19 * half — it holds the generator that writes this file.
1220 */
1321
1422 import { expect, test } from "bun:test";
15 import { tensorsOf, unpack } from "../engine/index.ts";
23 import { paramCount, tensorsOf, unpack, zeroPolicy } from "../engine/index.ts";
1624 import { HEADS } from "./action.ts";
1725 import { OBS_SIZE } from "./observe.ts";
18 import { PACKED } from "./weights.ts";
26 import { CARD, PACKED, type Card } from "./weights.ts";
1927
2028 /** What `unpack(PACKED)` has dequantised to since the weights were trained. */
2129 const DIGEST = "755d03516ba0122e6b30a04ac575c311e652ef12f38276fa0e5eb6289ed34103";
@@ -35,3 +43,150 @@ test("the shipped weights unpack to exactly the floats they did before", () => {
3543 hasher.update(new Uint8Array(t.buffer, t.byteOffset, t.byteLength));
3644 expect(hasher.digest("hex")).toBe(DIGEST);
3745 });
46
47 // --- the card -------------------------------------------------------------
48
49 /** Counts of things that happened: a whole number, and at least one of them. */
50 const COUNTS = ["decisions", "envSteps", "updates", "stepsPerSecond", "duels", "maps", "unseenMaps"] as const;
51
52 /** Shares of duels, in percent: a whole number between 0 and 100. */
53 const RATES = ["winRate", "baselineWinRate", "unseenWinRate", "unseenBaselineWinRate", "unseenDrawRate"] as const;
54
55 /** Every field of a card and the type it is, which is the whole type read out. */
56 const FIELDS: Record<keyof Card, "string" | "number"> = {
57 trainedAt: "string",
58 decisions: "number", envSteps: "number", updates: "number", stepsPerSecond: "number", minutes: "number",
59 duels: "number", winRate: "number", baselineWinRate: "number",
60 maps: "number", unseenMaps: "number",
61 unseenWinRate: "number", unseenBaselineWinRate: "number", unseenDrawRate: "number",
62 };
63
64 /**
65 * How far `minutes` and `stepsPerSecond` may disagree with `envSteps` before
66 * one of the three is not from the run the other two are. The trainer rounds
67 * minutes to a tenth and steps per second to a whole number, and the seconds it
68 * divides by include the evaluation duels at the end, so they never agree
69 * exactly; a tenth is far past any of that and still catches an order of
70 * magnitude, a missing field left at zero, or a number typed by hand.
71 */
72 const TOLERANCE = 0.1;
73
74 /**
75 * Everything wrong with a card, one sentence per fault, each naming the field
76 * it is about — empty for a card that could have come off a real run. It is a
77 * function rather than a wall of assertions so that each test below can corrupt
78 * one field and read back the complaint about that field and no other.
79 */
80 function faults(card: Card, now = Date.now()): string[] {
81 const out: string[] = [];
82 const whole = (v: unknown): boolean => typeof v === "number" && Number.isInteger(v);
83 for (const [field, type] of Object.entries(FIELDS))
84 if (typeof card[field as keyof Card] !== type) out.push(`${field} is not a ${type}`);
85
86 if (!/^\d{4}-\d{2}-\d{2}$/.test(card.trainedAt)) out.push("trainedAt is not an ISO date");
87 else {
88 const at = new Date(`${card.trainedAt}T00:00:00Z`);
89 if (Number.isNaN(at.getTime())) out.push("trainedAt is not a date that exists");
90 else if (at.toISOString().slice(0, 10) !== card.trainedAt) out.push("trainedAt is not a date that exists");
91 // A day's slack: the card is written in whatever timezone trained it.
92 else if (at.getTime() > now + 86_400_000) out.push("trainedAt is in the future");
93 }
94 for (const field of COUNTS)
95 if (!whole(card[field]) || card[field] < 1) out.push(`${field} is not a count of anything`);
96 for (const field of RATES)
97 if (!whole(card[field]) || card[field] < 0 || card[field] > 100) out.push(`${field} is not a percentage`);
98 if (!(card.minutes > 0) || !Number.isFinite(card.minutes)) out.push("minutes is not a length of time");
99
100 // The policy decides once every few ticks, so the ticks simulated are a whole
101 // number of decisions — and never fewer.
102 if (whole(card.envSteps) && whole(card.decisions) && (card.envSteps % card.decisions !== 0 || card.envSteps < card.decisions))
103 out.push("envSteps is not a whole number of decisions");
104 // The three numbers the boast is made of come from one run: ticks, the rate
105 // they were stepped at, and how long that took.
106 if (card.stepsPerSecond > 0 && card.minutes > 0 && Number.isFinite(card.envSteps)) {
107 const seconds = card.envSteps / card.stepsPerSecond;
108 const claimed = card.minutes * 60;
109 if (Math.abs(seconds - claimed) > TOLERANCE * Math.max(seconds, claimed))
110 out.push(`minutes and stepsPerSecond disagree with envSteps (${seconds.toFixed(0)}s of steps, ${claimed.toFixed(0)}s claimed)`);
111 }
112 return out;
113 }
114
115 test("the card the page boasts is a card: every field the type promises, and nothing wrong with any of them", () => {
116 expect(Object.keys(CARD).sort()).toEqual(Object.keys(FIELDS).sort());
117 for (const [field, type] of Object.entries(FIELDS)) expect(typeof CARD[field as keyof Card]).toBe(type);
118 expect(faults(CARD)).toEqual([]);
119 // And a card with a field missing is caught by its absence, not by luck.
120 const { winRate: _, ...missing } = CARD;
121 expect(faults(missing as Card)).toContain("winRate is not a number");
122 });
123
124 test("trainedAt is a date that exists and has happened", () => {
125 expect(CARD.trainedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
126 expect(new Date(`${CARD.trainedAt}T00:00:00Z`).toISOString().slice(0, 10)).toBe(CARD.trainedAt);
127 expect(Date.parse(`${CARD.trainedAt}T00:00:00Z`)).toBeLessThanOrEqual(Date.now() + 86_400_000);
128 for (const junk of ["", "today", "2026-9-1", "2026-13-01", "2026-02-31", "20260920"])
129 expect(faults({ ...CARD, trainedAt: junk })).toContain(
130 junk === "2026-13-01" || junk === "2026-02-31" ? "trainedAt is not a date that exists" : "trainedAt is not an ISO date",
131 );
132 // The run that has not happened yet is the half-finished one, and it fails.
133 const tomorrow = new Date(Date.now() + 3 * 86_400_000).toISOString().slice(0, 10);
134 expect(faults({ ...CARD, trainedAt: tomorrow })).toContain("trainedAt is in the future");
135 });
136
137 test("every count on the card is a whole number of something that happened", () => {
138 for (const field of COUNTS) {
139 expect(Number.isInteger(CARD[field])).toBe(true);
140 expect(CARD[field]).toBeGreaterThan(0);
141 // Corrupt exactly this one, three ways, and the complaint names it.
142 for (const junk of [0, -1, 1.5, Number.NaN])
143 expect(faults({ ...CARD, [field]: junk })).toContain(`${field} is not a count of anything`);
144 }
145 // The run is a real one: a few million ticks, a few hundred updates.
146 expect(CARD.envSteps).toBeGreaterThan(1_000_000);
147 expect(CARD.minutes).toBeGreaterThan(0);
148 expect(faults({ ...CARD, minutes: 0 })).toContain("minutes is not a length of time");
149 expect(faults({ ...CARD, minutes: Number.POSITIVE_INFINITY })).toContain("minutes is not a length of time");
150 });
151
152 test("every rate on the card is a percentage, and 999 is not one", () => {
153 for (const field of RATES) {
154 expect(Number.isInteger(CARD[field])).toBe(true);
155 expect(CARD[field]).toBeGreaterThanOrEqual(0);
156 expect(CARD[field]).toBeLessThanOrEqual(100);
157 for (const junk of [999, -1, 101, 71.5])
158 expect(faults({ ...CARD, [field]: junk })).toContain(`${field} is not a percentage`);
159 }
160 // The boast itself: the policy is measured against a baseline, on both sets
161 // of maps, and both baselines are real numbers rather than a stub of zero.
162 expect(CARD.baselineWinRate).toBeGreaterThan(0);
163 expect(CARD.unseenBaselineWinRate).toBeGreaterThan(0);
164 });
165
166 test("ticks, throughput and minutes are three numbers from one run", () => {
167 expect(CARD.envSteps % CARD.decisions).toBe(0);
168 expect(faults({ ...CARD, decisions: CARD.envSteps + 1 })).toContain("envSteps is not a whole number of decisions");
169 const seconds = CARD.envSteps / CARD.stepsPerSecond;
170 expect(Math.abs(seconds - CARD.minutes * 60)).toBeLessThanOrEqual(TOLERANCE * seconds);
171 // An honest retrain that is twice as fast, or half as long, still passes.
172 for (const over of [{ envSteps: CARD.envSteps * 2, decisions: CARD.decisions * 2, stepsPerSecond: CARD.stepsPerSecond * 2 },
173 { envSteps: CARD.envSteps / 2, decisions: CARD.decisions / 2, minutes: Math.round(CARD.minutes * 5) / 10 }])
174 expect(faults({ ...CARD, ...over })).toEqual([]);
175 // Nonsense does not: a minutes ten times the run, or a throughput that would
176 // have finished it in a second.
177 expect(faults({ ...CARD, minutes: CARD.minutes * 10 })[0]).toContain("disagree with envSteps");
178 expect(faults({ ...CARD, stepsPerSecond: CARD.envSteps })[0]).toContain("disagree with envSteps");
179 });
180
181 test("the packed weights are the shape the card was measured on, byte for byte", () => {
182 expect(PACKED.shape.obs).toBe(OBS_SIZE);
183 expect(PACKED.shape.heads).toEqual(HEADS);
184 // One scale per tensor, and one byte per parameter: the base64 decodes to
185 // exactly what the shape implies, so a truncated or a doubled string fails
186 // here rather than as a policy that quietly reads zeros.
187 expect(PACKED.scales.length).toBe(tensorsOf(zeroPolicy(PACKED.shape)).length);
188 expect(atob(PACKED.bytes).length).toBe(paramCount(PACKED.shape));
189 expect(atob(PACKED.bytes).length).toBe(8156);
190 expect(atob(`${PACKED.bytes.slice(0, -4)}`).length).not.toBe(paramCount(PACKED.shape));
191 for (const scale of PACKED.scales) expect(scale).toBeGreaterThan(0);
192 });
src/pages/game.test.ts added +125
@@ -0,0 +1,125 @@
1 /**
2 * The cs page, read the way a visitor gets it: a whole document from the shell,
3 * the leaderboard and the menu in the first paint, and — the reason this file
4 * exists — the paragraph about the bots.
5 *
6 * That paragraph is the only prose on the page made of measurements, and every
7 * number in it is the trainer's card in `src/client/weights.ts`. The check that
8 * it is *only* the card is the one below that renders the page with a card
9 * nobody trained: every number moves with it and not one of today's is left
10 * behind, which a number typed into the prose by hand could not do.
11 * `src/client/weights.test.ts` holds the card itself honest.
12 */
13
14 import { expect, test } from "bun:test";
15 import { mkdtempSync } from "node:fs";
16 import { tmpdir } from "node:os";
17 import { join } from "node:path";
18 import { openDatabase, type Player, saveMatch } from "../db.ts";
19 import type { Result } from "../result.ts";
20 import { CARD, type Card } from "../client/weights.ts";
21 import { GAMES } from "../games.ts";
22 import { gamePage, policyHtml, shareHref, BAD_ID, type PageOptions } from "./game.ts";
23 import { escape } from "./shell.ts";
24
25 const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "game-")));
26
27 const options: PageOptions = {
28 who: { role: "anon" },
29 env: "local",
30 pull: undefined,
31 commit: "abc1234",
32 script: "/static/app.0123456789.js",
33 ssh: undefined,
34 };
35
36 /** The policy paragraph, with its line breaks flattened the way a reader sees it. */
37 const boast = (html: string): string => {
38 const at = html.indexOf("<p>The bots path to a site");
39 expect(at).toBeGreaterThan(-1);
40 return html.slice(at, html.indexOf("</p>", at)).replace(/\s+/g, " ");
41 };
42
43 /** A card nobody trained: every number different from the shipped one's. */
44 const OTHER: Card = {
45 trainedAt: "2024-01-02",
46 decisions: 308_500, envSteps: 1_234_000, updates: 17, stepsPerSecond: 6_170, minutes: 3.4,
47 duels: 11, winRate: 88, baselineWinRate: 12,
48 maps: 7, unseenMaps: 5,
49 unseenWinRate: 66, unseenBaselineWinRate: 22, unseenDrawRate: 4,
50 };
51
52 test("the page is the document the shell renders, with one script and the game's own style", () => {
53 const html = gamePage(db(), options);
54 expect(html.startsWith(`<!doctype html>\n<html lang="en">\n`)).toBe(true);
55 expect(html).toContain("<title>cs — a browser defusal shooter</title>");
56 expect(html).toContain(`<script type="module" src="/static/app.0123456789.js"></script>`);
57 expect(html.match(/<script/g)?.length).toBe(1);
58 expect(html.match(/<h1/g)?.length).toBe(1);
59 expect(html).toContain("#thismatch code { user-select:all;");
60 for (const game of GAMES) expect(html).toContain(escape(game.label));
61 });
62
63 test("the leaderboard and the menu are in the first paint, not fetched", () => {
64 const home = db();
65 const player: Player = { userId: "u-1", session: "s1", name: "kestrel" };
66 const result: Result = {
67 matchId: crypto.randomUUID(), name: "ignored", rounds: 5, scoreMine: 5, scoreTheirs: 2,
68 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
69 };
70 expect(saveMatch(home, player, result)).toBe(true);
71 const html = gamePage(home, options);
72 expect(html).toContain("<h2>Leaderboard</h2>");
73 expect(html).toContain("<td>kestrel</td>");
74 expect(html).toContain("<h2>Defuse or detonate</h2>");
75 expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`);
76 });
77
78 test("a link that asked for a match is what the page opens with, and junk is a sentence", () => {
79 const id = "9f8a7b6c5d4e3f21";
80 const html = gamePage(db(), { ...options, asked: { match: id, badMatch: false, map: "generated" } });
81 expect(html).toContain(`<code id="playing">${id}</code>`);
82 expect(html).toContain(`href="${escape(shareHref({ match: id, badMatch: false, map: "generated" }))}"`);
83 expect(gamePage(db(), { ...options, asked: { match: null, badMatch: true, map: "de_forge" } }))
84 .toContain(escape(BAD_ID));
85 });
86
87 test("the boast about the bots is the card, formatted the way the page formats numbers", () => {
88 const said = boast(gamePage(db(), options));
89 expect(said).toContain(`trained with PPO against ${CARD.envSteps.toLocaleString("en")} ticks of this same simulation`);
90 expect(said).toContain(`in ${CARD.minutes.toLocaleString("en")} minutes on one core.`);
91 expect(said).toContain(
92 `In ${CARD.duels} fixed duels it wins ${CARD.winRate}% against the scripted bots this game used to have, ` +
93 `which win ${CARD.baselineWinRate}% of the same duels.`,
94 );
95 expect(said).toContain(
96 `It learned on ${CARD.maps} layouts — de_forge and ${CARD.maps - 1} carved from seeds — ` +
97 `and on ${CARD.unseenMaps} more it was never trained on it still wins ${CARD.unseenWinRate}% ` +
98 `against the same bots' ${CARD.unseenBaselineWinRate}%`,
99 );
100 // A count of millions is grouped, which is why it goes through a formatter
101 // rather than into the template raw: today that reads "8,192,000 ticks".
102 expect(CARD.envSteps.toLocaleString("en")).toContain(",");
103 expect(said).not.toContain(String(CARD.envSteps));
104 // And the paragraph the page renders is the one this module exports, so
105 // nothing between here and the page rewrites it.
106 expect(gamePage(db(), options)).toContain(policyHtml(CARD));
107 });
108
109 test("handed a card nobody trained, every number in the boast moves with it", () => {
110 const said = boast(gamePage(db(), { ...options, card: OTHER }));
111 expect(said).toContain("against 1,234,000 ticks of this same simulation, in 3.4 minutes on one core.");
112 expect(said).toContain("In 11 fixed duels it wins 88% against the scripted bots this game used to have, which win 12%");
113 expect(said).toContain("It learned on 7 layouts — de_forge and 6 carved from seeds — and on 5 more");
114 expect(said).toContain("still wins 66% against the same bots' 22%");
115 // Nothing of today's run survives into a paragraph about another run: a
116 // number written into the prose by hand would be sitting right here.
117 const as = (n: number): string => n.toLocaleString("en");
118 for (const stale of [
119 as(CARD.envSteps), `${as(CARD.minutes)} minutes`, `${as(CARD.duels)} fixed duels`,
120 `${as(CARD.winRate)}%`, `${as(CARD.baselineWinRate)}%`, `${as(CARD.maps)} layouts`,
121 `${as(CARD.unseenMaps)} more`, `${as(CARD.unseenWinRate)}%`, `${as(CARD.unseenBaselineWinRate)}%`,
122 ]) expect(said).not.toContain(stale);
123 // The prose around the numbers is the same prose: only the card moved.
124 expect(said.replace(/[\d,.]+/g, "#")).toBe(boast(gamePage(db(), options)).replace(/[\d,.]+/g, "#"));
125 });
src/pages/game.ts +39 −14
@@ -12,7 +12,7 @@
1212 import type { Database } from "bun:sqlite";
1313 import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts";
1414 import { NOTHING_POSTED } from "../client/pending.ts";
15 import { CARD } from "../client/weights.ts";
15 import { CARD, type Card } from "../client/weights.ts";
1616 import type { Who } from "../who.ts";
1717 import { navFor, othersHtml } from "./games.ts";
1818 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
@@ -66,6 +66,8 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
6666 who: Who;
6767 /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */
6868 asked?: Asked;
69 /** The policy card the prose is made of. Absent is the shipped weights'. */
70 card?: Card;
6971 };
7072
7173 /**
@@ -158,6 +160,37 @@ const menu = (visitor: Who, asked: Asked): string => `
158160 aim for the one before.</p>
159161 `;
160162
163 /**
164 * A number of the card's, as this page writes one: grouped by thousands, and
165 * escaped like any other value that came from a file rather than from here.
166 */
167 const num = (n: number): string => escape(n.toLocaleString("en"));
168
169 /**
170 * What the page says about the bots' policy — the only paragraph on it made of
171 * measurements. Every number in it comes from the card `scripts/train.ts` wrote
172 * into `src/client/weights.ts` and from nowhere else: a retrain that moves a
173 * win rate moves this sentence, and no number here is typed twice.
174 *
175 * It takes the card rather than reading the import so that `game.test.ts` can
176 * hand it a card nobody trained and read back a paragraph with none of today's
177 * numbers in it — which is how "the boast is the card" is checked rather than
178 * asserted.
179 */
180 export const policyHtml = (card: Card): string =>
181 `<p>The bots path to a site and plant or defuse on their own, but they do not aim by rule:
182 the fight is run by a small neural network trained with PPO against
183 ${num(card.envSteps)} ticks of this same simulation, in
184 ${num(card.minutes)} minutes on one core. In ${num(card.duels)} fixed duels it wins
185 ${num(card.winRate)}% against the scripted bots this game used to have, which win
186 ${num(card.baselineWinRate)}% of the same duels. It learned on ${num(card.maps)} layouts — de_forge and
187 ${num(card.maps - 1)} carved from seeds — and on ${num(card.unseenMaps)} more it was never trained on it
188 still wins ${num(card.unseenWinRate)}% against the same bots' ${num(card.unseenBaselineWinRate)}%, which is
189 what a map made the moment you press play asks of it. Its weights are about eight kilobytes
190 of int8 inside the bundle — there is no inference server and nothing is sent anywhere. Each
191 bot's own skill still decides how fast it can turn, how steady its hands are and how long it
192 takes to react, so there is still a ladder from fodder to uncomfortable.</p>`;
193
161194 /**
162195 * The pause panel. Its line about Escape is rendered with the sentence that is
163196 * true before any key has been pressed, and `src/client/main.ts` replaces it on
@@ -206,7 +239,10 @@ const thisMatch = (asked: Asked): string => ` <section id="thismatch">
206239 */
207240 const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite">${escape(NOTHING_POSTED)}</p>`;
208241
209 export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ...options }: PageOptions): string {
242 export function gamePage(
243 db: Database,
244 { who: visitor, asked = NOTHING_ASKED, card = CARD, ...options }: PageOptions,
245 ): string {
210246 return page({
211247 ...options,
212248 extraStyle: STYLE,
@@ -255,18 +291,7 @@ export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ..
255291 through a wall or over your shoulder.
256292 Everything you see is generated — the map, the textures, the models and the sounds — so the
257293 whole game is one small download and no asset files.</p>
258 <p>The bots path to a site and plant or defuse on their own, but they do not aim by rule:
259 the fight is run by a small neural network trained with PPO against
260 ${CARD.envSteps.toLocaleString("en")} ticks of this same simulation, in
261 ${escape(String(CARD.minutes))} minutes on one core. In ${CARD.duels} fixed duels it wins
262 ${CARD.winRate}% against the scripted bots this game used to have, which win
263 ${CARD.baselineWinRate}% of the same duels. It learned on ${CARD.maps} layouts — de_forge and
264 ${CARD.maps - 1} carved from seeds — and on ${CARD.unseenMaps} more it was never trained on it
265 still wins ${CARD.unseenWinRate}% against the same bots' ${CARD.unseenBaselineWinRate}%, which is
266 what a map made the moment you press play asks of it. Its weights are about eight kilobytes
267 of int8 inside the bundle — there is no inference server and nothing is sent anywhere. Each
268 bot's own skill still decides how fast it can turn, how steady its hands are and how long it
269 takes to react, so there is still a ladder from fodder to uncomfortable.</p>
294 ${policyHtml(card)}
270295 <p>The match runs entirely in your browser; the leaderboard here is what your browser
271296 reported about it, checked against what a match can produce. The name on a row is not
272297 the browser's word, though: signed in to the forge, your results are recorded under

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs