clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

fold: persistent chunked cell store, incremental settle and analysis, v1 logs

dami committed b86b2dc
Known cells live in a copy-on-write chunked store (1024 cells per chunk)
behind a read-only plain-object facade, so State.cells[id] and
Object.keys(State.cells) keep working while a fold of N events costs
O(N * touched chunks) and foldAll's states share memory. Per-tile known
counts make settle check only the tiles an event touched; the analysis
reads solved counters instead of walking tiles. Phase-1 logs (X, n grid
manifests with [tx, ty] tile names) are converted on load and replay to
solved. The 5071-event, 1777-tile log folds in ~0.5 s (it ran out of
memory before); a synthetic 4420-event, 2209-tile fold is asserted under
2 s with chunks shared between consecutive states.
commit
b86b2dcd7d4ce071d4cbf4a19aba83ed8a803a02
parent
a44848373911260842319f3912c612cd72a127a4
author
dami <gazey55@gmail.com>
4 files changed, +404 -56
Msite/lib/fold.ts308++++++++++++++++++++++++++----
Msite/lib/referee.ts29++-
Msite/tests/fold_test.ts109+++++++++++
Msite/tests/referee_test.ts14+-
M site/lib/fold.ts +269 -39
@@ -109,7 +109,24 @@ export type ManifestV2 = {
109109 library: string | null;110110 };111111 112-export type AnyManifest = Manifest | ManifestV2;112+/** phase-1 shape: an X-by-X grid of n-squares on a pitch of n-1; no tile list. */113+export type ManifestV1 = {114+ id: string;115+ seed_commit: string;116+ X: number;117+ n: number;118+ m: number;119+ G: number;120+ rules: Record<string, unknown>;121+ k: string;122+ generator: string;123+};124+125+export type AnyManifest = Manifest | ManifestV2 | ManifestV1;126+127+export function isV1(m: AnyManifest): m is ManifestV1 {128+ return !("version" in m) && "X" in m;129+}113130 114131 export type GameRules = { guess: "accept" | "reject"; depth: number };115132
@@ -132,6 +149,7 @@ export type ManifestEvent = {
132149 game_id: string;133150 manifest: AnyManifest;134151 rules: GameRules;152+ seed_tile?: [number, number]; // phase-1 logs: the seed tile as (tx, ty)135153 };136154 137155 /** An agent joining the field. */
@@ -158,7 +176,7 @@ export type RevealEvent = {
158176 kind: "batch";159177 op: "reveal";160178 agent?: string;161- tile: number;179+ tile: number; // phase-1 logs carry [tx, ty] here; the fold maps it (tileIdOf)162180 givens: [number, 0 | 1][];163181 seam: [number, 0 | 1][];164182 intent: string;
@@ -318,7 +336,12 @@ export type State = {
318336 seed_tile: number;319337 rules: GameRules;320338 tiles: TileStatus[]; // by tile id321- cells: Record<number, CellInfo>;339+ cells: Record<number, CellInfo>; // read-only façade over cellStore340+ cellStore: CellStore;341+ knownCount: Uint32Array; // per tile: known cells (givens, shared, commits)342+ solvedTiles: number;343+ solvedCells: number;344+ v1X: number; // X for a converted phase-1 log (reveal.tile is [tx, ty]); 0 otherwise322345 console: ConsoleEntry[];323346 status: "playing" | "solved";324347 totals: {
@@ -362,6 +385,136 @@ export type AgentStats = {
362385 seed_dist: number[]; // BFS distance from the agent's seed tile, by tile id363386 };364387 388+// ------------------------------------------------------------- cell store389+//390+// Known cells live in a persistent chunked store: an array of chunks of391+// CHUNK cells, copied on write per chunk, so a state derived from the392+// previous one shares every chunk the event did not touch. A fold of N393+// events over K known cells costs O(N * touched chunks), not O(N * K), and394+// foldAll's states share memory. `State.cells` is a read-only façade over395+// the store with plain-object semantics (`s.cells[id]`, `id in s.cells`,396+// `Object.keys(s.cells)`), so readers need not know; hot paths use397+// `cellAt` and `knownIds`.398+399+export const CHUNK = 1024;400+401+export type CellStore = {402+ chunks: ((CellInfo | undefined)[] | undefined)[];403+ size: number;404+};405+406+export function emptyStore(): CellStore {407+ return { chunks: [], size: 0 };408+}409+410+export function storeGet(st: CellStore, id: number): CellInfo | undefined {411+ const c = st.chunks[id >> 10];412+ return c === undefined ? undefined : c[id & 1023];413+}414+415+/** Every known id, ascending. */416+export function knownIds(st: CellStore): number[] {417+ const out: number[] = [];418+ for (let ci = 0; ci < st.chunks.length; ci++) {419+ const c = st.chunks[ci];420+ if (!c) continue;421+ for (let k = 0; k < c.length; k++) {422+ if (c[k] !== undefined) out.push((ci << 10) | k);423+ }424+ }425+ return out;426+}427+428+/** Copy-on-write writer: one copy of the chunk array and of each touched chunk per event. */429+class StoreWriter {430+ readonly chunks: CellStore["chunks"];431+ private touched = new Set<number>();432+ size: number;433+ constructor(prev: CellStore) {434+ this.chunks = prev.chunks.slice();435+ this.size = prev.size;436+ }437+ private chunk(ci: number): (CellInfo | undefined)[] {438+ let c = this.chunks[ci];439+ if (!this.touched.has(ci)) {440+ c = c ? c.slice() : new Array(CHUNK);441+ this.chunks[ci] = c;442+ this.touched.add(ci);443+ }444+ return c!;445+ }446+ get(id: number): CellInfo | undefined {447+ const c = this.chunks[id >> 10];448+ return c === undefined ? undefined : c[id & 1023];449+ }450+ set(id: number, info: CellInfo): void {451+ const c = this.chunk(id >> 10);452+ if (c[id & 1023] === undefined) this.size++;453+ c[id & 1023] = info;454+ }455+ delete(id: number): boolean {456+ const ci = id >> 10;457+ const cur = this.chunks[ci];458+ if (!cur || cur[id & 1023] === undefined) return false;459+ const c = this.chunk(ci);460+ c[id & 1023] = undefined;461+ this.size--;462+ return true;463+ }464+ store(): CellStore {465+ return { chunks: this.chunks, size: this.size };466+ }467+}468+469+const facadeCache = new WeakMap<CellStore, Record<number, CellInfo>>();470+471+/** A read-only plain-object view of a store (memoised per store). */472+export function cellsFacade(st: CellStore): Record<number, CellInfo> {473+ let f = facadeCache.get(st);474+ if (f) return f;475+ const keyOf = (p: string | symbol): number | null => {476+ if (typeof p !== "string") return null;477+ const n = Number(p);478+ return Number.isInteger(n) && n >= 0 && String(n) === p ? n : null;479+ };480+ f = new Proxy({} as Record<number, CellInfo>, {481+ get(_t, p) {482+ const k = keyOf(p);483+ return k === null ? undefined : storeGet(st, k);484+ },485+ has(_t, p) {486+ const k = keyOf(p);487+ return k !== null && storeGet(st, k) !== undefined;488+ },489+ ownKeys() {490+ return knownIds(st).map(String);491+ },492+ getOwnPropertyDescriptor(_t, p) {493+ const k = keyOf(p);494+ const v = k === null ? undefined : storeGet(st, k);495+ return v === undefined ? undefined : {496+ value: v,497+ writable: false,498+ enumerable: true,499+ configurable: true,500+ };501+ },502+ set() {503+ throw new Error("State.cells is read-only; fold events instead");504+ },505+ deleteProperty() {506+ throw new Error("State.cells is read-only; fold events instead");507+ },508+ });509+ facadeCache.set(st, f);510+ return f;511+}512+513+/** A known cell, or undefined (the fast path; `s.cells[id]` is the same). */514+export function cellAt(s: State, id: number): CellInfo | undefined {515+ return storeGet(s.cellStore, id);516+}517+365518 // ---------------------------------------------------------------- geometry366519 367520 export const HUES = 7;
@@ -418,7 +571,11 @@ export function tileCells(t: TileSpec, G: number): number[] {
418571 * shapes. Overlaps are recomputed from the geometry; zones are kept so419572 * their cells stay known from revision 0. A v3 manifest is returned as is.420573 */421-export function normaliseManifest(m: AnyManifest): Manifest {574+export function normaliseManifest(575+ m: AnyManifest,576+ seedXY?: [number, number],577+): Manifest {578+ if (isV1(m)) return normaliseV1(m, seedXY);422579 if (m.version === 3) return m;423580 const tiles: TileSpec[] = m.tiles.map((t) => ({424581 id: t.id,
@@ -454,6 +611,44 @@ export function normaliseManifest(m: AnyManifest): Manifest {
454611 return out;455612 }456613 614+/** phase-1: X-by-X squares of n on a pitch of n-1, ids in raster order. */615+function normaliseV1(m: ManifestV1, seedXY?: [number, number]): Manifest {616+ const tiles: TileSpec[] = [];617+ for (let ty = 0; ty < m.X; ty++) {618+ for (let tx = 0; tx < m.X; tx++) {619+ tiles.push({620+ id: tiles.length,621+ x0: tx * (m.n - 1),622+ y0: ty * (m.n - 1),623+ w: m.n,624+ h: m.n,625+ rects: [[0, 0, m.n, m.n]],626+ cells: m.n * m.n,627+ m: m.m,628+ overlaps: [],629+ });630+ }631+ }632+ const out: Manifest = {633+ version: 3,634+ id: m.id,635+ seed_commit: m.seed_commit,636+ G: m.G,637+ packer: { kind: "grid", params: { X: m.X, n: m.n } },638+ m_by_n: { [String(m.n)]: m.m },639+ rules: m.rules,640+ seed_tile: seedXY ? seedXY[1] * m.X + seedXY[0] : 0,641+ tiles,642+ k: m.k,643+ generator: m.generator,644+ library: null,645+ source_version: 1,646+ };647+ const geo = buildGeometry(out);648+ for (const t of tiles) t.overlaps = overlapsOf(geo, t.id);649+ return out;650+}651+457652 /** Shared-cell counts between a tile and each tile it touches. */458653 export function overlapsOf(geo: Geometry, tile: number): Overlap[] {459654 const counts = new Map<number, number>();
@@ -623,7 +818,12 @@ export function emptyState(): State {
623818 seed_tile: 0,624819 rules: { guess: "accept", depth: 0 },625820 tiles: [],626- cells: {},821+ cells: cellsFacade(emptyStore()),822+ cellStore: emptyStore(),823+ knownCount: new Uint32Array(0),824+ solvedTiles: 0,825+ solvedCells: 0,826+ v1X: 0,627827 console: [],628828 status: "playing",629829 totals: {
@@ -740,10 +940,26 @@ export function joinCandidates(s: State): number[] {
740940 return out;741941 }742942 743-function tileKnownCount(s: State, t: TileSpec): number {744- let k = 0;745- for (const id of s.geo!.cellsOf[t.id]) if (s.cells[id]) k++;746- return k;943+/**944+ * Mark a cell known (no-op if it already is), keeping the per-tile known945+ * counts, and return the owner tiles so settle can check just those.946+ */947+function markKnown(s: State, w: StoreWriter, id: number, info: CellInfo): void {948+ if (w.get(id) !== undefined) return;949+ w.set(id, info);950+ const geo = s.geo!;951+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {952+ s.knownCount[geo.ownerList[i]]++;953+ }954+}955+956+function unmarkKnown(s: State, w: StoreWriter, id: number): boolean {957+ if (!w.delete(id)) return false;958+ const geo = s.geo!;959+ for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {960+ s.knownCount[geo.ownerList[i]]--;961+ }962+ return true;747963 }748964 749965 /** BFS distance over the overlap graph from one tile. */
@@ -824,20 +1040,28 @@ function settle(
8241040 s: State,8251041 agent: string,8261042 rev: number,1043+ candidates: Iterable<number>,8271044 ): { newly: number[]; reward: number; meets: Meet[] } {8281045 const newly: number[] = [];8291046 const meets: Meet[] = [];8301047 let reward = 0;8311048 if (!s.manifest) return { newly, reward, meets };832- for (const t of s.manifest.tiles) {833- if (s.tiles[t.id] !== "revealed") continue;834- if (tileKnownCount(s, t) === t.cells) {835- s.tiles[t.id] = "solved";836- s.tileAgent[t.id] = agent;837- newly.push(t.id);1049+ const seen = new Set<number>();1050+ for (const id of candidates) {1051+ if (seen.has(id)) continue;1052+ seen.add(id);1053+ if (s.tiles[id] !== "revealed") continue;1054+ const t = s.manifest.tiles[id];1055+ if (s.knownCount[id] === t.cells) {1056+ s.tiles[id] = "solved";1057+ s.tileAgent[id] = agent;1058+ newly.push(id);8381059 reward += t.cells;8391060 }8401061 }1062+ newly.sort((a, b) => a - b);1063+ s.solvedTiles += newly.length;1064+ s.solvedCells += reward;8411065 if (newly.length) {8421066 const st = s.agentStats[agent];8431067 st.tiles += newly.length;
@@ -885,15 +1109,7 @@ function classify(st: AgentStats): Strategy {
8851109 /** Recompute the analysis from the state's counters (mutates s.analysis). */8861110 function analyse(s: State): void {8871111 const t = s.totals;888- let solvedCells = 0, solvedTiles = 0;889- if (s.manifest) {890- for (const tile of s.manifest.tiles) {891- if (s.tiles[tile.id] === "solved") {892- solvedTiles++;893- solvedCells += tile.cells;894- }895- }896- }1112+ const solvedCells = s.solvedCells, solvedTiles = s.solvedTiles;8971113 const recent = s.batchSizes.slice(-10);8981114 const agents: AgentAnalysis[] = s.agents.map((a) => {8991115 const st = s.agentStats[a.id];
@@ -971,10 +1187,11 @@ function scoreReveal(
9711187 9721188 /** Apply one event, returning a new state. The input is not mutated. */9731189 export function applyEvent(prev: State, ev: GameEvent): State {1190+ const w = new StoreWriter(prev.cellStore);9741191 const s: State = {9751192 ...prev,9761193 tiles: [...prev.tiles],977- cells: { ...prev.cells },1194+ knownCount: new Uint32Array(prev.knownCount),9781195 console: [...prev.console],9791196 totals: { ...prev.totals },9801197 tileAgent: [...prev.tileAgent],
@@ -1009,7 +1226,8 @@ export function applyEvent(prev: State, ev: GameEvent): State {
10091226 };10101227 switch (ev.kind) {10111228 case "manifest": {1012- const m = normaliseManifest(ev.manifest);1229+ const m = normaliseManifest(ev.manifest, ev.seed_tile);1230+ s.v1X = isV1(ev.manifest) ? ev.manifest.X : 0;10131231 s.game_id = ev.game_id;10141232 s.manifest = m;10151233 s.geo = buildGeometry(m);
@@ -1018,8 +1236,9 @@ export function applyEvent(prev: State, ev: GameEvent): State {
10181236 s.rules = ev.rules;10191237 s.tiles = new Array(m.tiles.length).fill("hidden");10201238 s.tileAgent = new Array(m.tiles.length).fill(null);1239+ s.knownCount = new Uint32Array(m.tiles.length);10211240 for (const [id, v] of zoneCells(m)) {1022- s.cells[id] = { v, kind: "zone", rev: 0 };1241+ markKnown(s, w, id, { v, kind: "zone", rev: 0 });10231242 }10241243 const sizes = m.tiles.map((t) => t.cells);10251244 const seeds = 1 +
@@ -1060,35 +1279,40 @@ export function applyEvent(prev: State, ev: GameEvent): State {
10601279 const agent = ensureAgent(s, ev.agent);10611280 const st = s.agentStats[agent];10621281 if (ev.op === "reveal") {1282+ const tile = tileIdOf(s, ev.tile);10631283 const frontier = frontierOf(s, agent); // before the reveal lands1064- s.tiles[ev.tile] = "revealed";1284+ s.tiles[tile] = "revealed";1285+ const touched: number[] = [tile];10651286 for (const [id, v] of ev.givens) {1066- if (!s.cells[id]) s.cells[id] = { v, kind: "given", rev: ev.rev };1287+ markKnown(s, w, id, { v, kind: "given", rev: ev.rev });1288+ touched.push(...ownersOf(s.geo!, id));10671289 }10681290 s.totals.reveals++;1069- scoreReveal(s, agent, ev.tile, frontier.length ? frontier : [ev.tile]);1070- const { newly, reward, meets } = settle(s, agent, ev.rev);1291+ scoreReveal(s, agent, tile, frontier.length ? frontier : [tile]);1292+ const { newly, reward, meets } = settle(s, agent, ev.rev, touched);10711293 s.totals.reward += reward;10721294 s.console.push({10731295 ...base,10741296 op: "reveal",10751297 agent,10761298 intent: ev.intent,1077- tile: ev.tile,1299+ tile,10781300 revealed_cells: ev.givens.length + ev.seam.length,10791301 solved: newly,10801302 reward,10811303 ...(meets.length ? { meets } : {}),10821304 });10831305 } else if (ev.op === "commit") {1306+ const touched: number[] = [];10841307 for (const c of ev.accepted) {1085- s.cells[c.id] = {1308+ markKnown(s, w, c.id, {10861309 v: c.v,10871310 kind: "commit",10881311 class: c.class,10891312 rules: c.rules,10901313 rev: ev.rev,1091- };1314+ });1315+ touched.push(...ownersOf(s.geo!, c.id));10921316 if (c.class === "forced") {10931317 s.totals.forced++;10941318 st.forced++;
@@ -1104,7 +1328,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
11041328 st.wrong += wrong;11051329 s.batchSizes.push(ev.accepted.length);11061330 if (base.dt_ms !== null) s.gaps.push(base.dt_ms);1107- const { newly, reward, meets } = settle(s, agent, ev.rev);1331+ const { newly, reward, meets } = settle(s, agent, ev.rev, touched);11081332 s.totals.reward += reward;11091333 s.console.push({11101334 ...base,
@@ -1123,10 +1347,7 @@ export function applyEvent(prev: State, ev: GameEvent): State {
11231347 } else if (ev.op === "retract") {11241348 let n = 0;11251349 for (const id of ev.cells) {1126- if (s.cells[id]?.kind === "commit") {1127- delete s.cells[id];1128- n++;1129- }1350+ if (w.get(id)?.kind === "commit" && unmarkKnown(s, w, id)) n++;11301351 }11311352 s.totals.retracts++;11321353 s.totals.retracted += n;
@@ -1149,10 +1370,19 @@ export function applyEvent(prev: State, ev: GameEvent): State {
11491370 }11501371 }11511372 s.last_t = ev.t;1373+ s.cellStore = w.store();1374+ s.cells = cellsFacade(s.cellStore);11521375 analyse(s);11531376 return s;11541377 }11551378 1379+/** phase-1 logs name tiles as [tx, ty]; everything since uses the id. */1380+function tileIdOf(s: State, tile: number): number {1381+ const t = tile as unknown;1382+ if (Array.isArray(t)) return (t[1] as number) * s.v1X + (t[0] as number);1383+ return tile;1384+}1385+11561386 export function fold(events: GameEvent[]): State {11571387 let s = emptyState();11581388 for (const ev of events) s = applyEvent(s, ev);
M site/lib/referee.ts +18 -11
@@ -7,6 +7,7 @@ import {
77 agentOf,88 allSolved,99 applyEvent,10+ cellAt,1011 cellId,1112 type CommitEvent,1213 deltaEncode,
@@ -19,8 +20,10 @@ import {
1920 IMPLICIT_AGENT,2021 isHole,2122 isOnFrontier,23+ isV1,2224 joinCandidates,2325 type JoinEvent,26+ knownIds,2427 type LocalRect,2528 type ManifestEvent,2629 neighboursOf,
@@ -169,7 +172,10 @@ export class Referee {
169172 judgeFn: Judge,170173 opts: { persist?: boolean; seed?: string } = {},171174 ): Promise<Referee> {172- if (gen.manifest.version !== 2 && gen.manifest.version !== 3) {175+ if (176+ isV1(gen.manifest) ||177+ (gen.manifest.version !== 2 && gen.manifest.version !== 3)178+ ) {173179 throw new RefereeError(174180 "Generator output is not manifest v2 or v3.",175181 "version",
@@ -242,7 +248,7 @@ export class Referee {
242248 const out: [number, 0 | 1][] = [];243249 const givens = new Set(this.givensByTile.get(t.id)!.map(([id]) => id));244250 for (const id of this.state.geo!.cellsOf[t.id]) {245- const c = this.state.cells[id];251+ const c = cellAt(this.state, id);246252 if (c && !givens.has(id)) out.push([id, c.v]);247253 }248254 return out;
@@ -475,10 +481,11 @@ export class Referee {
475481 ignored.push({ id, reason: "hole" });476482 continue;477483 }478- if (this.state.cells[id]) {484+ const known = cellAt(this.state, id);485+ if (known) {479486 ignored.push({480487 id,481- reason: this.state.cells[id].kind === "given" ? "given" : "known",488+ reason: known.kind === "given" ? "given" : "known",482489 });483490 continue;484491 }
@@ -496,9 +503,9 @@ export class Referee {
496503 "empty",497504 );498505 }499- const known: [number, number][] = Object.entries(this.state.cells).map((500- [id, c],501- ) => [Number(id), c.v]);506+ const known: [number, number][] = knownIds(this.state.cellStore).map((507+ id,508+ ) => [id, cellAt(this.state, id)!.v]);502509 const judged = await this.judgeFn({503510 G,504511 shapes: this.activeShapes().map((t) => ({
@@ -605,7 +612,7 @@ export class Referee {
605612 if (!Number.isInteger(id)) {606613 throw new RefereeError("Cell ids are integers.", "shape");607614 }608- if (this.state.cells[id]?.kind === "commit") retracted.push(id);615+ if (cellAt(this.state, id)?.kind === "commit") retracted.push(id);609616 else ignored.push(id);610617 }611618 if (retracted.length === 0) {
@@ -702,7 +709,7 @@ export class Referee {
702709 let k = 0;703710 if (st !== "hidden") {704711 for (const id of geo.cellsOf[t.id]) {705- if (s.cells[id]) {712+ if (cellAt(s, id)) {706713 k++;707714 known.add(id);708715 }
@@ -724,7 +731,7 @@ export class Referee {
724731 });725732 }726733 const ids = [...known].sort((a, b) => a - b);727- const committed = ids.filter((id) => s.cells[id].kind === "commit");734+ const committed = ids.filter((id) => cellAt(s, id)!.kind === "commit");728735 return {729736 game_id: this.gameId,730737 revision: s.revision,
@@ -743,7 +750,7 @@ export class Referee {
743750 tiles_total: this.manifest.tiles.length,744751 holes: geo.holes,745752 known_deltas: deltaEncode(ids),746- known_values: ids.map((id) => String(s.cells[id].v)).join(""),753+ known_values: ids.map((id) => String(cellAt(s, id)!.v)).join(""),747754 committed_deltas: deltaEncode(committed),748755 totals: s.totals,749756 encoding: {
M site/tests/fold_test.ts +109 -0
@@ -11,6 +11,8 @@ import {
1111 deltaDecode,1212 deltaEncode,1313 fold,14+ foldAll,15+ type GameEvent,1416 hueOf,1517 HUES,1618 isHole,
@@ -235,3 +237,110 @@ Deno.test("delta coding round trips", () => {
235237 const ids = [5, 1, 900, 901, 42];236238 assertEquals(deltaDecode(deltaEncode(ids)), [1, 5, 42, 900, 901]);237239 });240+241+// The fold is incremental with structural sharing: a daily-sized synthetic242+// game (every tile revealed and committed in one batch, ~2 events per tile)243+// folds with every intermediate state kept in well under 2 s, and244+// `State.cells` reads like a plain object.245+Deno.test("fold throughput on a daily-sized field, states share memory", () => {246+ const out = synthesise(scatterLayout("throughput", 530, 0.85), "t");247+ const m = out.manifest;248+ const G = m.G;249+ const geo = buildGeometry(m);250+ const t = "2026-01-01T00:00:00.000Z";251+ const events: GameEvent[] = [{252+ rev: 0,253+ t,254+ kind: "manifest",255+ game_id: "g",256+ manifest: m,257+ rules: { guess: "accept", depth: 0 },258+ }];259+ events.push({260+ rev: 1,261+ t,262+ kind: "join",263+ agent: "a0",264+ harness: "test",265+ seed_tile: m.seed_tile,266+ });267+ // reveal + commit every tile in frontier order (BFS over the overlap graph)268+ const order: number[] = [m.seed_tile];269+ const seen = new Set(order);270+ for (let i = 0; i < order.length; i++) {271+ for (const u of geo.adjacency[order[i]]) {272+ if (!seen.has(u)) {273+ seen.add(u);274+ order.push(u);275+ }276+ }277+ }278+ const known = new Set<number>();279+ for (const id of order) {280+ const givens = out.tiles[id].givens.filter(([c]) => !known.has(c));281+ givens.forEach(([c]) => known.add(c));282+ events.push({283+ rev: events.length,284+ t,285+ kind: "batch",286+ op: "reveal",287+ agent: "a0",288+ tile: id,289+ givens,290+ seam: [],291+ intent: "",292+ });293+ const accepted = geo.cellsOf[id].filter((c) => !known.has(c)).map((c) => {294+ known.add(c);295+ return {296+ id: c,297+ v: (out.solution[Math.floor(c / G)][c % G] === "1" ? 1 : 0) as 0 | 1,298+ class: "forced" as const,299+ rules: ["balance"],300+ };301+ });302+ events.push({303+ rev: events.length,304+ t,305+ kind: "batch",306+ op: "commit",307+ agent: "a0",308+ intent: "",309+ accepted,310+ rejected: [],311+ ignored: [],312+ solved: [],313+ judge_ms: 0,314+ });315+ }316+ const t0 = performance.now();317+ const states = foldAll(events);318+ const ms = performance.now() - t0;319+ const last = states.at(-1)!;320+ assert(m.tiles.length > 1500, `${m.tiles.length} tiles`);321+ assert(ms < 2000, `fold of ${events.length} events took ${ms.toFixed(0)} ms`);322+ assertEquals(last.solvedTiles, m.tiles.length);323+ assertEquals(last.cellStore.size, Object.keys(last.cells).length);324+ assertEquals(325+ last.cells[order[0] * 0 + geo.cellsOf[m.seed_tile][0]]?.v,326+ out327+ .solution[Math.floor(geo.cellsOf[m.seed_tile][0] / G)][328+ geo.cellsOf[m.seed_tile][0] % G329+ ] === "1"330+ ? 1331+ : 0,332+ );333+ // structural sharing: consecutive states share untouched chunks334+ const a = states[states.length - 2].cellStore.chunks;335+ const b = last.cellStore.chunks;336+ let shared = 0;337+ for (let i = 0; i < a.length; i++) {338+ if (a[i] !== undefined && a[i] === b[i]) shared++;339+ }340+ assert(shared > a.length / 2, `only ${shared} of ${a.length} chunks shared`);341+ console.log(342+ `fold ${events.length} events / ${m.tiles.length} tiles: ${343+ ms.toFixed(0)344+ } ms`,345+ );346+});
M site/tests/referee_test.ts +8 -6
@@ -62,9 +62,9 @@ async function play(
6262 });6363 acc = applyEvent(acc, ref.log.events[0]);6464 stream.push(acc);65- live.push(structuredClone(ref.state));65+ live.push(ref.state); // states are immutable: a reference is a snapshot6666 const { agent } = await ref.join({ name: "player", harness: "test" });67- live.push(structuredClone(ref.state));67+ live.push(ref.state); // states are immutable: a reference is a snapshot6868 const sol = solutionOf(out);6969 const geo = ref.state.geo!;7070 while (ref.state.status === "playing") {
@@ -73,7 +73,7 @@ async function play(
7373 const id = fr[0];7474 if (ref.state.tiles[id] === "hidden") {7575 await ref.reveal(ref.revision, id, `reveal #${id}`, agent);76- live.push(structuredClone(ref.state));76+ live.push(ref.state); // states are immutable: a reference is a snapshot7777 }7878 const unknown = geo.cellsOf[id].filter((c) => !ref.state.cells[c]);7979 const batch = unknown.slice(0, 40).map((c) =>
@@ -89,7 +89,7 @@ async function play(
8989 const hole = holeOf(ref.state);9090 if (hole >= 0) batch.push([hole, 0]);9191 await ref.commit(ref.revision, batch, `fill #${id}`, agent);92- live.push(structuredClone(ref.state));92+ live.push(ref.state); // states are immutable: a reference is a snapshot9393 }9494 return { ref, live, stream, gameId, out, agent };9595 }
@@ -307,9 +307,11 @@ Deno.test("on-disk game logs replay to a solved end state", async () => {
307307 if (text === null) continue;308308 const events = parseLog(text);309309 if (events.at(-1)?.kind !== "end") continue; // unfinished game310- const manifestEvent = events[0] as { manifest?: { version?: number } };310+ const manifestEvent = events[0] as {311+ manifest?: { version?: number; X?: number };312+ };311313 const v = manifestEvent.manifest?.version;312- if (v !== 2 && v !== 3) continue; // phase-1 shape314+ if (v !== 2 && v !== 3 && !manifestEvent.manifest?.X) continue;313315 const replay = foldAll(events);314316 for (let k = 0; k < events.length; k++) {315317 assertEquals(events[k].rev, k, `${id}: contiguous revisions`);