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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/111-what-the-trainer-writes-into-the-reposit → main 2 commits fork
6 files
changed +622
−65
README.md +27
| @@ -963,6 +963,33 @@ now a policy learned with PPO. | ||
| 963 | 963 | dequantisation when the page loads, and roughly 7,700 multiply-adds per bot |
| 964 | 964 | per decision: nine bots deciding at 16 Hz is well under a millisecond a second. |
| 965 | 965 | |
| 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 | ||
| 966 | 993 | When the gun is empty, it puts it away. A bot cannot buy mid-round and there is |
| 967 | 994 | nothing on the floor to pick up, so a gun that has spent its last magazine stays |
| 968 | 995 | 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 @@ | ||
| 6 | 6 | * bun run scripts/train.ts --iters 300 # train and write the weights |
| 7 | 7 | * bun run scripts/train.ts --iters 300 --dry # train without writing |
| 8 | 8 | * 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 | |
| 9 | 10 | * |
| 10 | 11 | * Nothing here is imported by the server or the page: this file runs on a |
| 11 | 12 | * machine with a few minutes to spare, and its only output is a table of |
| @@ -14,16 +15,22 @@ | ||
| 14 | 15 | * The PPO is the engine's, reached through the door like everything else it |
| 15 | 16 | * offers; what is the shooter's is the arena it collects from, the observation |
| 16 | 17 | * 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. | |
| 17 | 24 | */ |
| 18 | 25 | |
| 19 | 26 | import { writeFileSync } from "node:fs"; |
| 20 | import { join } from "node:path"; | |
| 27 | import { join, resolve } from "node:path"; | |
| 21 | 28 | import { HEADS, decode } from "../src/client/action.ts"; |
| 22 | 29 | import { botThink, makeBrain } from "../src/client/bots.ts"; |
| 23 | 30 | import { |
| 24 | 31 | computeAdvantages, copyPolicy, createAdam, createPolicy, createRollout, forward, greedy, |
| 25 | 32 | newActivations, pack, rng, sample, unpack, update, DEFAULT_HYPER, |
| 26 | type Hyper, type Policy, type Shape, | |
| 33 | type Hyper, type Packed, type Policy, type Shape, | |
| 27 | 34 | } from "../src/engine/index.ts"; |
| 28 | 35 | import { OBS_SIZE } from "../src/client/observe.ts"; |
| 29 | 36 | import { |
| @@ -33,25 +40,105 @@ import { | ||
| 33 | 40 | import { holdoutLevels, trainingLevels } from "../src/rl/maps.ts"; |
| 34 | 41 | import { DE_FORGE } from "../src/client/map.ts"; |
| 35 | 42 | import type { Level } from "../src/client/level.ts"; |
| 43 | import type { Card } from "../src/client/weights.ts"; | |
| 36 | 44 | |
| 37 | 45 | export const SHAPE: Shape = { obs: OBS_SIZE, hidden: 64, heads: HEADS }; |
| 38 | 46 | |
| 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 --------------------------------------------------------- | |
| 46 | 48 | |
| 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 | } | |
| 55 | 142 | |
| 56 | 143 | // --- evaluation ----------------------------------------------------------- |
| 57 | 144 | |
| @@ -104,10 +191,10 @@ export function greedyDriver(policy: Policy): (arena: Arena) => void { | ||
| 104 | 191 | |
| 105 | 192 | // --- the loop ------------------------------------------------------------- |
| 106 | 193 | |
| 107 | function benchmark(): void { | |
| 108 | const levels = trainingLevels(MAPS); | |
| 194 | function benchmark({ maps, seed }: Options): void { | |
| 195 | const levels = trainingLevels(maps); | |
| 109 | 196 | 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)); | |
| 111 | 198 | const obs = new Float32Array(OBS_SIZE); |
| 112 | 199 | const policy = createPolicy(SHAPE, rng(1)); |
| 113 | 200 | const act = newActivations(SHAPE); |
| @@ -128,7 +215,8 @@ function benchmark(): void { | ||
| 128 | 215 | console.log(`${steps} steps in ${seconds.toFixed(2)}s = ${(steps / seconds / 1000).toFixed(0)}k steps/s (observe + policy + arena)`); |
| 129 | 216 | } |
| 130 | 217 | |
| 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; | |
| 132 | 220 | const hyper: Hyper = { ...DEFAULT_HYPER, minibatch: Math.max(256, (ENVS * STEPS) / 4) }; |
| 133 | 221 | const random = rng(SEED); |
| 134 | 222 | const policy = createPolicy(SHAPE, random); |
| @@ -153,10 +241,9 @@ async function train(): Promise<void> { | ||
| 153 | 241 | // against the scripted bots at full skill and against itself. Handed a hard |
| 154 | 242 | // opponent from the first step, the policy learns to hide instead of to |
| 155 | 243 | // shoot, and never comes back from it. |
| 156 | const only = process.argv[process.argv.indexOf("--foe") + 1]; | |
| 157 | 244 | 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; | |
| 160 | 247 | const still = Math.max(0.1, 0.6 - progress * 0.5); |
| 161 | 248 | const scripted = 0.3 + progress * 0.3; |
| 162 | 249 | const u = (e % 10) / 10; |
| @@ -281,8 +368,9 @@ async function train(): Promise<void> { | ||
| 281 | 368 | console.log(` scripted ${unseenBaseline.won}/${unseenBaseline.lost}/${unseenBaseline.draw} ` + |
| 282 | 369 | `(${rate(unseenBaseline)}% won, ${draws(unseenBaseline)}% drawn)`); |
| 283 | 370 | |
| 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; | |
| 286 | 374 | writeFileSync(out, weightsModule(packed, { |
| 287 | 375 | trainedAt: new Date().toISOString().slice(0, 10), |
| 288 | 376 | decisions: envSteps, |
| @@ -302,24 +390,14 @@ async function train(): Promise<void> { | ||
| 302 | 390 | console.log(`\nwrote ${out} (${packed.bytes.length} base64 chars)`); |
| 303 | 391 | } |
| 304 | 392 | |
| 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 { | |
| 323 | 401 | return `/** |
| 324 | 402 | * The trained policy, written by scripts/train.ts. Do not edit by hand. |
| 325 | 403 | * |
| @@ -363,6 +441,15 @@ export const PACKED: Packed = { | ||
| 363 | 441 | } |
| 364 | 442 | |
| 365 | 443 | 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); | |
| 368 | 455 | } |
src/client/weights.test.ts +160 −5
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 5 | 6 | * |
| 6 | 7 | * The digest is sha256 over every tensor's bytes in `tensorsOf` order, which is |
| 7 | 8 | * the order packing walks them in. It is here because #106 moved the network |
| @@ -9,13 +10,20 @@ | ||
| 9 | 10 | * weight by one ulp would otherwise be invisible until a bot missed a shot, and |
| 10 | 11 | * the seeded duel in `src/rl/learned.test.ts` would be the first to notice. |
| 11 | 12 | * 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. | |
| 12 | 20 | */ |
| 13 | 21 | |
| 14 | 22 | import { expect, test } from "bun:test"; |
| 15 | import { tensorsOf, unpack } from "../engine/index.ts"; | |
| 23 | import { paramCount, tensorsOf, unpack, zeroPolicy } from "../engine/index.ts"; | |
| 16 | 24 | import { HEADS } from "./action.ts"; |
| 17 | 25 | import { OBS_SIZE } from "./observe.ts"; |
| 18 | import { PACKED } from "./weights.ts"; | |
| 26 | import { CARD, PACKED, type Card } from "./weights.ts"; | |
| 19 | 27 | |
| 20 | 28 | /** What `unpack(PACKED)` has dequantised to since the weights were trained. */ |
| 21 | 29 | const DIGEST = "755d03516ba0122e6b30a04ac575c311e652ef12f38276fa0e5eb6289ed34103"; |
| @@ -35,3 +43,150 @@ test("the shipped weights unpack to exactly the floats they did before", () => { | ||
| 35 | 43 | hasher.update(new Uint8Array(t.buffer, t.byteOffset, t.byteLength)); |
| 36 | 44 | expect(hasher.digest("hex")).toBe(DIGEST); |
| 37 | 45 | }); |
| 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 @@ | ||
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; |
| 14 | 14 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 15 | import { CARD } from "../client/weights.ts"; | |
| 15 | import { CARD, type Card } from "../client/weights.ts"; | |
| 16 | 16 | import type { Who } from "../who.ts"; |
| 17 | 17 | import { navFor, othersHtml } from "./games.ts"; |
| 18 | 18 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| @@ -66,6 +66,8 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 66 | 66 | who: Who; |
| 67 | 67 | /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */ |
| 68 | 68 | asked?: Asked; |
| 69 | /** The policy card the prose is made of. Absent is the shipped weights'. */ | |
| 70 | card?: Card; | |
| 69 | 71 | }; |
| 70 | 72 | |
| 71 | 73 | /** |
| @@ -158,6 +160,37 @@ const menu = (visitor: Who, asked: Asked): string => ` | ||
| 158 | 160 | aim for the one before.</p> |
| 159 | 161 | `; |
| 160 | 162 | |
| 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 | ||
| 161 | 194 | /** |
| 162 | 195 | * The pause panel. Its line about Escape is rendered with the sentence that is |
| 163 | 196 | * 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"> | ||
| 206 | 239 | */ |
| 207 | 240 | const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite">${escape(NOTHING_POSTED)}</p>`; |
| 208 | 241 | |
| 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 { | |
| 210 | 246 | return page({ |
| 211 | 247 | ...options, |
| 212 | 248 | extraStyle: STYLE, |
| @@ -255,18 +291,7 @@ export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, .. | ||
| 255 | 291 | through a wall or over your shoulder. |
| 256 | 292 | Everything you see is generated — the map, the textures, the models and the sounds — so the |
| 257 | 293 | 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)} | |
| 270 | 295 | <p>The match runs entirely in your browser; the leaderboard here is what your browser |
| 271 | 296 | reported about it, checked against what a match can produce. The name on a row is not |
| 272 | 297 | the browser's word, though: signed in to the forge, your results are recorded under |
new above
offline