clone

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

main
mosaic / site / tests / health_test.ts
health_test.ts 7.2K
1// Health: an agent's wrong-rate over its last 2,000 committed cells. Honest2// play stays healthy; a warning heals as the window flushes; death at 4%3// (after 300 cells) is sticky and refuses every later write; the oracle4// probe (every unknown cell as 0, then the complements) dies before it5// has bled 300 wrong cells.6import { assert, assertEquals, assertRejects } from "@std/assert";7import {8 applyEvent,9 type CommitEvent,10 frontierOf,11 HEALTH_DEATH,12 HEALTH_WARN,13 HEALTH_WINDOW,14 type State,15} from "../lib/fold.ts";16import type { GenOutput } from "../lib/engine.ts";17import { Referee, RefereeError } from "../lib/referee.ts";18import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";1920const tmp = await Deno.makeTempDir();21Deno.env.set("MOSAIC_GAMES", tmp + "/");2223const fixture = synthesise(scatterLayout("health", 100));2425function solutionOf(out: GenOutput) {26 const G = out.manifest.G;27 return (id: number) =>28 (out.solution[Math.floor(id / G)][id % G] === "1" ? 1 : 0) as 0 | 1;29}3031async function game() {32 const out = structuredClone(fixture);33 const ref = await Referee.create(34 crypto.randomUUID(),35 out,36 { guess: "accept", depth: 0 },37 stubJudge,38 { seed: "health" },39 );40 const { agent } = await ref.join({ name: "player", harness: "test" });41 return { ref, agent, sol: solutionOf(out) };42}4344/** Reveal the next frontier tile and return its unknown cells. */45async function nextTile(ref: Referee, agent: string): Promise<number[]> {46 const fr = frontierOf(ref.state, agent);47 assert(fr.length > 0, "frontier");48 const id = fr[0];49 if (ref.state.tiles[id] === "hidden") {50 await ref.reveal(ref.revision, id, `reveal #${id}`, agent);51 }52 return ref.state.geo!.cellsOf[id].filter((c) => !ref.state.cells[c]);53}5455/**56 * Commit `cells` correct cells with one wrong value every `wrongEvery`57 * cells (0 = none), tile by tile, then fix the wrong ones correctly.58 */59async function feed(60 ref: Referee,61 agent: string,62 sol: (id: number) => 0 | 1,63 cells: number,64 wrongEvery: number,65) {66 let done = 0;67 let k = 0;68 while (done < cells) {69 const unknown = await nextTile(ref, agent);70 if (!unknown.length) continue;71 const batch: [number, number][] = [];72 const fix: [number, number][] = [];73 for (const c of unknown) {74 k++;75 if (wrongEvery && k % wrongEvery === 0) {76 batch.push([c, (1 - sol(c)) as 0 | 1]);77 fix.push([c, sol(c)]);78 } else batch.push([c, sol(c)]);79 }80 await ref.commit(ref.revision, batch, "feed", agent);81 done += batch.length;82 if (fix.length) {83 await ref.commit(ref.revision, fix, "fix", agent);84 done += fix.length;85 }86 }87}8889function health(ref: Referee, agent: string) {90 return ref.state.agents.find((a) => a.id === agent)!.health;91}9293Deno.test("honest play stays healthy", async () => {94 const { ref, agent, sol } = await game();95 await feed(ref, agent, sol, 600, 0);96 const h = health(ref, agent);97 assertEquals(h.state, "healthy");98 assertEquals(h.wrong, 0);99 assert(h.committed >= 600);100 assertEquals(h.window, HEALTH_WINDOW);101});102103Deno.test("a warning heals as the window flushes", async () => {104 const { ref, agent, sol } = await game();105 // 2% wrong: warning, not death106 await feed(ref, agent, sol, 400, 50);107 let h = health(ref, agent);108 assertEquals(h.state, "warning");109 assert(h.rate >= HEALTH_WARN && h.rate < HEALTH_DEATH, `rate ${h.rate}`);110 const few = (await nextTile(ref, agent)).slice(0, 3).map((c) =>111 [c, sol(c)] as [number, number]112 );113 const r = await ref.commit(ref.revision, few, "one more", agent);114 assert(r.note && r.note.startsWith("warning"), "commit carries the note");115 // a full window of honest cells later: healthy116 await feed(ref, agent, sol, HEALTH_WINDOW, 0);117 h = health(ref, agent);118 assertEquals(h.state, "healthy");119 assertEquals(h.wrong, 0);120 assertEquals(h.committed, HEALTH_WINDOW);121});122123Deno.test("death at 4% is sticky and refuses every write", async () => {124 const { ref, agent, sol } = await game();125 // 5% wrong: dead once 300 cells are in the window126 await feed(ref, agent, sol, 320, 20).catch((e) => {127 if (!(e instanceof RefereeError && e.code === "dead")) throw e;128 });129 const h = health(ref, agent);130 assertEquals(h.state, "dead");131 assert(h.died_at !== null && h.died_at > 0);132 assert(h.committed >= 300);133 const obs = ref.observe({ agent });134 assertEquals(obs.health?.state, "dead");135 assert(obs.note?.startsWith("dead"));136 for (137 const write of [138 () => ref.reveal(ref.revision, frontierOf(ref.state)[0], "r", agent),139 () => ref.commit(ref.revision, [[0, 0]], "c", agent),140 () => ref.retract(ref.revision, [0], "r", agent),141 ]142 ) {143 const err = await assertRejects(write, RefereeError);144 assertEquals(err.code, "dead");145 }146 // sticky: a window of correct cells folded on top does not revive it147 // (distinct cells: a machine is credited for a cell only once)148 let s: State = ref.state;149 const geo = s.geo!;150 const pool: number[] = [];151 for (let t = 0; t < s.tiles.length && pool.length < 60 * 40; t++) {152 if (s.tiles[t] !== "hidden") continue;153 for (const c of geo.cellsOf[t]) if (!s.cells[c]) pool.push(c);154 }155 assert(pool.length >= 60 * 40, "enough distinct cells to flush the window");156 for (let i = 0; i < 60; i++) {157 const cells = pool.slice(i * 40, i * 40 + 40);158 const ev: CommitEvent = {159 rev: s.revision + 1,160 t: new Date().toISOString(),161 kind: "batch",162 op: "commit",163 agent,164 accepted: cells.map((c) => ({165 id: c,166 v: sol(c),167 class: "forced",168 rules: ["run"],169 })),170 rejected: [],171 ignored: [],172 intent: "after death",173 judge_ms: 0,174 solved: [],175 meets: [],176 };177 s = applyEvent(s, ev);178 }179 const after = s.agents.find((a) => a.id === agent)!.health;180 assertEquals(after.state, "dead");181 assertEquals(after.died_at, h.died_at);182 assertEquals(after.wrong, 0); // the window healed; the state did not183});184185Deno.test("the oracle probe bleeds out before 300 wrong cells", async () => {186 const { ref, agent } = await game();187 const pending = new Map<number, 0 | 1>();188 let wrongTotal = 0;189 let dead = false;190 for (let round = 0; round < 200 && !dead; round++) {191 try {192 if (pending.size) {193 const batch = [...pending].map(([id, v]) =>194 [id, v] as [number, number]195 );196 pending.clear();197 await ref.commit(ref.revision, batch, "complements", agent);198 continue;199 }200 const unknown = await nextTile(ref, agent);201 if (!unknown.length) continue;202 const r = await ref.commit(203 ref.revision,204 unknown.map((c) => [c, 0] as [number, number]),205 "probe zeros",206 agent,207 );208 for (const rej of r.rejected) {209 if (rej.reason === "wrong") {210 wrongTotal++;211 pending.set(rej.id, (1 - rej.v!) as 0 | 1);212 }213 }214 if (r.health.state === "dead") dead = true;215 } catch (e) {216 if (e instanceof RefereeError && e.code === "dead") {217 dead = true;218 } else throw e;219 }220 }221 assert(dead, "the probe died");222 assert(wrongTotal < 300, `bled ${wrongTotal} wrong cells before dying`);223 const err = await assertRejects(224 () => ref.commit(ref.revision, [[1, 1]], "again", agent),225 RefereeError,226 );227 assertEquals(err.code, "dead");228});