clone

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

main
mosaic / site / tests / guards_test.ts
guards_test.ts 10.1K
1// Guards against the three attacks an agent reported (rejected commits as2// an oracle, reading the public event log, reversing tile hashes; the last3// one lives in engine/gen) and their variants: verdicts stop at death4// inside a batch, all sessions of a machine share one health, retract and5// recommit cannot launder the window, sessions per machine are capped,6// a keyless join is refused on a hosted field, and the public log carries7// no value for a rejected cell.8import { assert, assertEquals, assertRejects } from "@std/assert";9import { frontierOf, HEALTH_BURST } from "../lib/fold.ts";10import type { GenOutput } from "../lib/engine.ts";11import {12 MAX_BATCH,13 Referee,14 RefereeError,15 SESSIONS_PER_MACHINE,16} from "../lib/referee.ts";17import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";1819const tmp = await Deno.makeTempDir();20Deno.env.set("MOSAIC_GAMES", tmp + "/");2122const fixture = synthesise(scatterLayout("guards", 100));2324function solutionOf(out: GenOutput) {25 const G = out.manifest.G;26 return (id: number) =>27 (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;28}2930async function game(identity: "required" | "optional" = "optional") {31 const out = structuredClone(fixture);32 const ref = await Referee.create(33 crypto.randomUUID(),34 out,35 { guess: "accept", depth: 0, identity },36 stubJudge,37 { seed: "guards", persist: true },38 );39 return { ref, sol: solutionOf(out) };40}4142const MACHINE = "m-0123456789abcdef";4344async function seat(ref: Referee, session: string) {45 const j = await ref.join({46 name: "dami",47 harness: "test",48 machine_id: MACHINE,49 verified: true,50 session,51 });52 return j.agent;53}5455/** `n` unknown cells of the agent's own revealed tiles. Reveals hidden56 * frontier tiles as needed and, when the frontier is exhausted, solves the57 * oldest revealed tile (correctly) to grow it; those cells are dropped from58 * the answer. */59async function unknownCells(60 ref: Referee,61 agent: string,62 n: number,63 sol: (id: number) => 0 | 1,64): Promise<number[]> {65 const tiles: number[] = [];66 const geo = ref.state.geo!;67 const unknownOf = (t: number) =>68 geo.cellsOf[t].filter((c) => !ref.state.cells[c]);69 for (const t of frontierOf(ref.state, agent)) {70 if (ref.state.tiles[t] !== "hidden") tiles.push(t);71 }72 const count = () => tiles.reduce((k, t) => k + unknownOf(t).length, 0);73 while (count() < n) {74 const hidden = frontierOf(ref.state, agent).find((t) =>75 ref.state.tiles[t] === "hidden"76 );77 if (hidden !== undefined) {78 await ref.reveal(ref.revision, hidden, `reveal #${hidden}`, agent);79 tiles.push(hidden);80 continue;81 }82 const t = tiles.shift();83 assert(t !== undefined, "a tile to solve");84 await ref.commit(85 ref.revision,86 unknownOf(t).map((c) => [c, sol(c)] as [number, number]),87 `solve #${t}`,88 agent,89 );90 }91 const out: number[] = [];92 const seen = new Set<number>();93 for (const t of tiles) {94 for (const c of unknownOf(t)) {95 if (!seen.has(c)) {96 seen.add(c);97 out.push(c);98 }99 }100 }101 assert(out.length >= n, `wanted ${n} unknown cells, have ${out.length}`);102 return out.slice(0, n);103}104105Deno.test("a batch's verdicts stop at the cell that kills the machine", async () => {106 const { ref, sol } = await game();107 const agent = await seat(ref, "probe");108 const cells = await unknownCells(ref, agent, MAX_BATCH, sol);109 // every cell wrong: the burst rule kills at the 30th wrong110 const r = await ref.commit(111 ref.revision,112 cells.map((c) => [c, (1 - sol(c)) as 0 | 1]),113 "probe",114 agent,115 );116 assertEquals(r.health.state, "dead");117 assertEquals(r.rejected.length, HEALTH_BURST);118 assertEquals(r.unevaluated.length, cells.length - HEALTH_BURST);119 assert(120 r.rejected.every((c) => c.v !== undefined),121 "the caller gets the verdicts it earned",122 );123 const ev = ref.log.events.at(-1) as {124 rejected: { v?: number }[];125 unevaluated?: number[];126 died_at?: number;127 };128 assert(129 ev.rejected.every((c) => c.v === undefined),130 "the public record carries no value",131 );132 assertEquals(ev.unevaluated?.length, cells.length - HEALTH_BURST);133 assertEquals(ev.died_at, ref.revision);134 // the next write is refused before any evaluation135 const err = await assertRejects(136 () => ref.commit(ref.revision, [[cells[0], sol(cells[0])]], "again", agent),137 RefereeError,138 );139 assertEquals(err.code, "dead");140});141142Deno.test("a batch over the cap is refused whole", async () => {143 const { ref } = await game();144 const agent = await seat(ref, "big");145 const cells = Array.from(146 { length: MAX_BATCH + 1 },147 (_, i) => [i, 0] as [number, number],148 );149 const err = await assertRejects(150 () => ref.commit(ref.revision, cells, "too many", agent),151 RefereeError,152 );153 assertEquals(err.code, "batch");154});155156Deno.test("all sessions of a machine share one health: a probe kills the harvester too", async () => {157 const { ref, sol } = await game();158 const a = await seat(ref, "probe");159 const b = await seat(ref, "harvest");160 assert(a !== b);161 const cells = await unknownCells(ref, a, 200, sol);162 await ref.commit(163 ref.revision,164 cells.map((c) => [c, (1 - sol(c)) as 0 | 1]),165 "probe",166 a,167 );168 assertEquals(ref.observe({ agent: a }).health?.state, "dead");169 assertEquals(ref.observe({ agent: b }).health?.state, "dead");170 // the harvester reads the public log for rejected values: none171 for (const ev of ref.log.events) {172 if (ev.kind !== "batch" || ev.op !== "commit") continue;173 for (const r of ev.rejected) assert(!("v" in r), "no value in the log");174 }175 const err = await assertRejects(176 () => ref.commit(ref.revision, [[cells[0], sol(cells[0])]], "harvest", b),177 RefereeError,178 );179 assertEquals(err.code, "dead");180 // one machine death, recorded once181 assertEquals(182 ref.state.agents.filter((x) => x.health.state === "dead").length,183 2,184 );185 const deaths = ref.log.events.filter((e) =>186 e.kind === "batch" && "died_at" in e187 );188 assertEquals(deaths.length, 1);189});190191Deno.test("two sessions probing concurrently end with exactly one death and no verdict past it", async () => {192 const { ref, sol } = await game();193 const a = await seat(ref, "one");194 const b = await seat(ref, "two");195 const ca = await unknownCells(ref, a, 60, sol);196 const cb = await unknownCells(ref, b, 60, sol);197 const rev = ref.revision;198 const results = await Promise.allSettled([199 ref.commit(rev, ca.map((c) => [c, (1 - sol(c)) as 0 | 1]), "p1", a),200 ref.commit(rev, cb.map((c) => [c, (1 - sol(c)) as 0 | 1]), "p2", b),201 ]);202 // writes are serialised: one lands (and kills), the other conflicts or is refused dead203 const landed = results.filter((r) => r.status === "fulfilled");204 assertEquals(landed.length, 1);205 const wrongs = ref.log.events.filter((e) =>206 e.kind === "batch" && e.op === "commit"207 )208 .reduce((n, e) => n + (e as { rejected: unknown[] }).rejected.length, 0);209 assertEquals(wrongs, HEALTH_BURST);210 assertEquals(ref.observe({ agent: b }).health?.state, "dead");211});212213Deno.test("retract and recommit cannot launder the window; a wrong recommit still bleeds", async () => {214 const { ref, sol } = await game();215 const agent = await seat(ref, "launder");216 // 10 wrong among 500 correct: warning territory (2%)217 const cells = await unknownCells(ref, agent, 510, sol);218 const batch = cells.map((c, i) =>219 [c, i < 10 ? (1 - sol(c)) as 0 | 1 : sol(c)] as [number, number]220 );221 for (let i = 0; i < batch.length; i += 100) {222 await ref.commit(ref.revision, batch.slice(i, i + 100), "play", agent);223 }224 const before = ref.observe({ agent }).health!;225 assertEquals(before.wrong, 10);226 const correct = cells.slice(10);227 // retract 500 correct cells and recommit them: the window is unchanged228 for (let i = 0; i < correct.length; i += 100) {229 await ref.retract(230 ref.revision,231 correct.slice(i, i + 100),232 "retract",233 agent,234 );235 await ref.commit(236 ref.revision,237 correct.slice(i, i + 100).map((c) => [c, sol(c)] as [number, number]),238 "recommit",239 agent,240 );241 }242 const after = ref.observe({ agent }).health!;243 assertEquals(after.committed, before.committed);244 assertEquals(after.wrong, before.wrong);245 assertEquals(after.rate, before.rate);246 // the analyzer still counts every commit247 assert(ref.state.totals.commits > 6);248 assertEquals(ref.state.totals.wrong, 10);249 // a wrong recommit of a counted cell bleeds250 await ref.retract(ref.revision, [correct[0]], "retract one", agent);251 await ref.commit(252 ref.revision,253 [[correct[0], (1 - sol(correct[0])) as 0 | 1]],254 "flip",255 agent,256 );257 const flipped = ref.observe({ agent }).health!;258 assertEquals(flipped.wrong, before.wrong + 1);259 assertEquals(flipped.committed, before.committed + 1);260});261262Deno.test("sessions per machine are capped for the day; rejoin is idempotent; the cap survives a death", async () => {263 const { ref, sol } = await game();264 const sessions: string[] = [];265 for (let i = 0; i < SESSIONS_PER_MACHINE; i++) {266 sessions.push(await seat(ref, `s${i}`));267 }268 const err = await assertRejects(() => seat(ref, "extra"), RefereeError);269 assertEquals(err.code, "sessions");270 // rejoining an existing session is the same agent, no new event271 const n = ref.log.events.length;272 assertEquals(await seat(ref, "s0"), sessions[0]);273 assertEquals(ref.log.events.length, n);274 // die, then a new session is still refused275 const cells = await unknownCells(ref, sessions[0], 60, sol);276 await ref.commit(277 ref.revision,278 cells.map((c) => [c, (1 - sol(c)) as 0 | 1]),279 "probe",280 sessions[0],281 );282 assertEquals(ref.observe({ agent: sessions[0] }).health?.state, "dead");283 const err2 = await assertRejects(() => seat(ref, "afterlife"), RefereeError);284 assertEquals(err2.code, "sessions");285});286287Deno.test("a hosted field refuses a keyless join", async () => {288 const { ref } = await game("required");289 const err = await assertRejects(290 () => ref.join({ name: "anon", harness: "test" }),291 RefereeError,292 );293 assertEquals(err.code, "identity");294 assert(err.message.includes("/identity"));295 const agent = await seat(ref, "1");296 assertEquals(agent, `${MACHINE}/1`);297});