clone

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

site: health — an agent's wrong-rate over its last 2,000 committed cells

dami committed 7e075f1
Guessing bleeds you. Per agent, a ring of the last HEALTH_WINDOW (2,000)
committed cells (accepted + wrong) gives a wrong-rate: under 1.5%
healthy, 1.5-4% warning, 4% and above once 300 cells are in the window
death for the day: reveal, commit and retract are refused (code
"dead"), territory stays, group mates continue, tomorrow is fresh.
Death is sticky; the window heals the other states on its own. Commit
results and observe {agent} carry health {state, rate, committed,
wrong, window, died_at} and a note at warning; the rules and prompt
describe health as a fact of the field and no longer tell agents what
they cannot do anyway; leaderboard rows carry wrong_rate and dead.
Tests: honest play healthy, warning heals, death sticky and refusing,
the oracle probe (every cell as 0, then complements) dies before 300
wrong cells.
commit
7e075f19fcbd3030b080619693ce3ae7160eec49
parent
944ffbb81e088fd192451ffd6ea3f3dbe4aaf44d
author
dami <gazey55@gmail.com>
7 files changed, +422 -15
Msite/lib/fold.ts107++++++++++++++-
Msite/lib/leaderboard.ts13++
Msite/lib/mcp.ts18++-
Msite/lib/prompt.ts17+-
Msite/lib/referee.ts57++++++++-
Asite/tests/health_test.ts222++++++++++++++++++++++++++++++
Msite/tests/viewer_test.ts3+
M site/lib/fold.ts +105 -2
@@ -45,6 +45,13 @@
4545 // tiles as the faintest wash; observe under fog lists a4646 // hidden tile only when the caller's own group reaches it)4747 // tileRevealer[] per tile id: the agent that revealed it (null while hidden)48+// agents[].health {state, rate, committed, wrong, window, died_at}: the49+// agent's wrong-rate over its last HEALTH_WINDOW committed50+// cells (accepted + wrong): healthy under HEALTH_WARN,51+// warning up to HEALTH_DEATH, dead at HEALTH_DEATH once at52+// least HEALTH_MIN cells are in the window. Death is sticky53+// for the game (died_at = the revision); the window heals54+// the other states on its own. Guessing bleeds you.4855 // seedRings seed tiles per ring (ring 0 = the initial field); a4956 // joining agent is seated on the newest ring first5057
@@ -295,6 +302,27 @@ export type GameEvent =
295302 296303 export const IMPLICIT_AGENT = "a0";297304 305+export type HealthState = "healthy" | "warning" | "dead";306+307+/** An agent's health: its wrong-rate over a window of committed cells. */308+export type Health = {309+ state: HealthState;310+ rate: number; // wrong / committed over the window311+ committed: number; // cells in the window (accepted + wrong)312+ wrong: number; // wrong cells in the window313+ window: number; // HEALTH_WINDOW314+ died_at: number | null; // revision the agent died at315+};316+317+/** Cells of the health window. */318+export const HEALTH_WINDOW = 2000;319+/** Wrong-rate from which an agent is warned. */320+export const HEALTH_WARN = 0.015;321+/** Wrong-rate at which an agent dies for the game. */322+export const HEALTH_DEATH = 0.04;323+/** Committed cells in the window before death can apply. */324+export const HEALTH_MIN = 300;325+298326 export type AgentInfo = {299327 id: string;300328 name: string;
@@ -304,6 +332,7 @@ export type AgentInfo = {
304332 joined: number; // revision of the join event (0 for the implicit agent)305333 seed_tile: number;306334 group: string; // union-find root agent id335+ health: Health;307336 };308337 309338 export type Strategy = "bfs" | "dfs" | "greedy" | "mixed" | "none";
@@ -331,6 +360,7 @@ export type AgentAnalysis = {
331360 reveals: number;332361 };333362 last_solved: number | null; // tile id363+ health: Health;334364 };335365 336366 export type Analysis = {
@@ -459,8 +489,65 @@ export type AgentStats = {
459489 greedy: number;460490 last_solved: number | null;461491 seed_dist: number[]; // BFS distance from the agent's seed tile, by tile id492+ // the health window: a ring of outcomes (0 accepted, 1 wrong), copied on493+ // write so earlier states keep theirs494+ outcomes: Uint8Array;495+ opos: number; // next write position in the ring496+ ofill: number; // cells in the ring497+ owrong: number; // wrong cells in the ring498+ died_at: number | null;462499 };463500 501+/** A fresh agent's health. */502+export function healthyHealth(): Health {503+ return {504+ state: "healthy",505+ rate: 0,506+ committed: 0,507+ wrong: 0,508+ window: HEALTH_WINDOW,509+ died_at: null,510+ };511+}512+513+/** The health read off an agent's window. */514+export function healthOf(st: AgentStats): Health {515+ const rate = st.ofill ? st.owrong / st.ofill : 0;516+ let state: HealthState = "healthy";517+ if (st.died_at !== null) state = "dead";518+ else if (rate >= HEALTH_WARN) state = "warning";519+ return {520+ state,521+ rate,522+ committed: st.ofill,523+ wrong: st.owrong,524+ window: HEALTH_WINDOW,525+ died_at: st.died_at,526+ };527+}528+529+/** Record `n` outcomes of one kind on the window (mutates st; the ring is530+ * copied first so the previous state keeps its own). */531+function recordOutcomes(st: AgentStats, wrong: boolean, n: number): void {532+ if (n <= 0) return;533+ st.outcomes = new Uint8Array(st.outcomes);534+ for (let i = 0; i < n; i++) {535+ if (st.ofill === HEALTH_WINDOW) {536+ // the ring is full: the slot being overwritten leaves the window537+ if (st.outcomes[st.opos] === 1) st.owrong--;538+ } else {539+ st.ofill++;540+ }541+ st.outcomes[st.opos] = wrong ? 1 : 0;542+ if (wrong) st.owrong++;543+ st.opos = (st.opos + 1) % HEALTH_WINDOW;544+ }545+}546+547+export function isDead(s: State, agent: string): boolean {548+ return agentOf(s, agent)?.health.state === "dead";549+}550+464551 // ------------------------------------------------------------- cell store465552 //466553 // Known cells live in a persistent chunked store: an array of chunks of
@@ -1112,6 +1199,11 @@ function newAgentStats(geo: Geometry, seed: number): AgentStats {
11121199 greedy: 0,11131200 last_solved: null,11141201 seed_dist: distancesFrom(geo, seed),1202+ outcomes: new Uint8Array(HEALTH_WINDOW),1203+ opos: 0,1204+ ofill: 0,1205+ owrong: 0,1206+ died_at: null,11151207 };11161208 }11171209
@@ -1123,9 +1215,9 @@ function seedStats(geo: Geometry, seed: number): Pick<AgentStats, "seed_dist"> {
11231215 /** Register an agent in the state (mutates s). */11241216 function addAgent(11251217 s: State,1126- info: Omit<AgentInfo, "group">,1218+ info: Omit<AgentInfo, "group" | "health">,11271219 ): AgentInfo {1128- const a: AgentInfo = { ...info, group: info.id };1220+ const a: AgentInfo = { ...info, group: info.id, health: healthyHealth() };11291221 s.agents = [...s.agents, a];11301222 s.agentStats = {11311223 ...s.agentStats,
@@ -1257,6 +1349,7 @@ function analyse(s: State): void {
12571349 reveals: st.reveals,12581350 },12591351 last_solved: st.last_solved,1352+ health: healthOf(st),12601353 };12611354 });12621355 s.analysis = {
@@ -1517,6 +1610,16 @@ export function applyEvent(prev: State, ev: GameEvent): State {
15171610 const wrong = ev.rejected.filter((r) => r.reason === "wrong").length;15181611 s.totals.wrong += wrong;15191612 st.wrong += wrong;1613+ recordOutcomes(st, false, ev.accepted.length);1614+ recordOutcomes(st, true, wrong);1615+ if (1616+ st.died_at === null && st.ofill >= HEALTH_MIN &&1617+ st.owrong / st.ofill >= HEALTH_DEATH1618+ ) {1619+ st.died_at = ev.rev;1620+ }1621+ const health = healthOf(st);1622+ s.agents = s.agents.map((a) => a.id === agent ? { ...a, health } : a);15201623 s.batchSizes.push(ev.accepted.length);15211624 if (base.dt_ms !== null) s.gaps.push(base.dt_ms);15221625 const { newly, reward, meets } = settle(s, agent, ev.rev, touched);
M site/lib/leaderboard.ts +13 -0
M site/lib/mcp.ts +13 -5
@@ -67,7 +67,7 @@ function findImplementation(o: unknown): string | undefined {
6767 }6868 6969 const RULES_TEXT =70- `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}, and since cells are binary a rejection is full information: the other value is correct, commit it next. Correct cells are accepted and classified forced or guess. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. GROWING FIELDS AND FOG: a field started with a grow policy is allocated on a large canvas (G up to 1000) with the initial chunks in the middle, and it GROWS: on a timer, when most current tiles are revealed or solved, and whenever every current tile is solved, the referee generates a ring of new chunks around the cluster (a grow event; tile ids continue, existing cells never change). Observe again after each batch and expect new tile ids; the game ends only when a growth finds no room. Under FOG (on by default for growing fields) observe lists a hidden tile only once your group reaches it: a tile you revealed or solved overlaps it, or it is your seed tile; everything else is unlisted until then, so expanding toward open space is how you discover the map. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;70+ `Rules of a mosaic. The board is a G x G field of binary cells partly covered by tiles. A tile is a shape: the union of rectangles {rects: [lx, ly, w, h]} inside its bounding box at (x0, y0); tiles come in many sizes and shapes (rectangles, L, T, plus). Neighbouring tiles overlap: a shared cell belongs to every tile that covers it and has one value. Cells no tile covers are holes: never playable. Inside every tile, independently, the rules apply per LINE, where a line is a maximal horizontal or vertical run of the tile's cells: each line has as many 0s as 1s; no three equal values are adjacent along a line; no two lines of the same orientation and the same length within one tile are equal. Nothing is enforced across a tile boundary except that shared cells agree. Every tile ALONE (its givens only) has 2..m solutions, m depending on its cell count (2 up to 64 cells, 3 up to 196, 4 above), and its alternatives differ on cells it shares with SOME neighbour. PINNING IS NOT GUARANTEED BY ANY ONE NEIGHBOUR: a solved neighbour fixes the cells it shares, which pins the tile only if the alternatives differ there; a tile can keep 2 or more solutions with one solved neighbour and need another, or a guess. AGENTS: several agents play one field. Call join to get your agent id and your seed tile, a hidden tile at a random spot away from everyone; every reveal, commit and retract carries your agent id. Your frontier is your seed tile while unsolved, plus every unsolved tile sharing a cell with a tile your group solved. When a tile you solve touches a tile solved by another group, you meet: the two territories merge and from then on share one frontier. Tiles are referred to by id; observe {agent: your id} lists your territory, everything within 24 cells of it and your frontier: by design that includes HIDDEN tiles' shapes, cell counts and m before they are revealed (the map is public; only givens and values are earned), each tile with id, x0, y0, w, h, rects, cells, m, status, agent (who solved it) and neighbours; observe {rect} lists a rectangle; full listings exist only on small fields. Givens are hidden until you reveal a tile, and reveal is only accepted on your frontier. Your seed tile has no solved neighbour, so at least one guess is unavoidable there; everywhere else, prefer to expand from a solved tile and use the shared cells. A tile is solved when all of its cells are known (givens, cells fixed by solved neighbours, and correct commits); solving a tile earns its cell count as reward and attributes the tile to you. COMMITS are checked per cell against the true solution: a wrong value is rejected and reported as {id, reason: "wrong", v}; correct cells are accepted and classified forced or guess. HEALTH: your health is your wrong-rate over your last 2,000 committed cells (accepted + wrong): under 1.5% healthy; 1.5% to 4% warning (commit and observe results say so); 4% and above, once at least 300 cells are in the window, death for the day: your writes are refused from then on, your territory stays, your group mates continue, tomorrow is fresh. The window is what heals: an honest rate flushes old mistakes. Guessing bleeds you; probing cells to read the verdict bleeds you out. Commit what you can prove; guess only when a tile is genuinely ambiguous, and then one cell. FORCED means the referee's own propagation reaches the value from the cells known in revealed tiles (givens, shared cells, accepted commits): repeatedly, for every empty cell of every revealed tile and each candidate value, the value is excluded on any line through the cell when (run) it would make three equal in a row along that line, in any of the three positions: the two cells before it, the two after it, or one on each side; (balance) the line already holds len/2 cells of that value; (unique) the line is complete except this cell, or is complete except this cell and one more blank, and with this value it would equal another complete line of the same orientation and length in the same tile (with two blanks the balance rule fills the last one identically, so both forms count). A cell with exactly one admissible value is fixed, every fixed cell is re-evaluated, until nothing changes; bifurcation (assume a value, propagate, contradiction excludes it) only to the game's configured depth, default 0. That is single-cell propagation of the three rules; distinct-line counting, pair/gap arguments beyond the three positions above, and cross-line reasoning are NOT applied, so a cell that holds in every solution but needs them is recorded as guess. Commit propagation-forced cells first if you care about that split. Some shared cells may be givens; cells the referee already knows, and holes, are ignored, not rejected. Every write carries expected_revision; on conflict (other agents are writing too), observe again and retry. TRANSPORT: this endpoint serves the 2025-era streamable HTTP protocol statelessly: there is no Mcp-Session-Id (do not expect one), initialize answers as one SSE frame, notifications/initialized returns 202 at once, and long tool calls (start_mosaic no longer blocks) send SSE keepalive comments. GROWING FIELDS AND FOG: a field started with a grow policy is allocated on a large canvas (G up to 1000) with the initial chunks in the middle, and it GROWS: on a timer, when most current tiles are revealed or solved, and whenever every current tile is solved, the referee generates a ring of new chunks around the cluster (a grow event; tile ids continue, existing cells never change). Observe again after each batch and expect new tile ids; the game ends only when a growth finds no room. Under FOG (on by default for growing fields) observe lists a hidden tile only once your group reaches it: a tile you revealed or solved overlaps it, or it is your seed tile; everything else is unlisted until then, so expanding toward open space is how you discover the map. Writing code to do the deduction is allowed; where to expand (toward open space, or toward another agent to merge frontiers) and how much to commit is your strategy.`;7171 7272 // a uuid for games agents start, `day-<date>` for the daily7373 const gameIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
@@ -288,7 +288,7 @@ function registerPlay(server: McpServer): void {
288288 {289289 title: "Observe the mosaic",290290 description:291- "Read the current revision and a scope of tiles (id, x0, y0, w, h, rects, cells, m, status, agent, neighbours) with the frontier and every known cell in them (delta-coded ids plus a value string; decode by cumulative sum). On a growing field tiles_total and grown rise over time; under fog hidden tiles appear only once your group reaches them. Scope: {agent: your id} lists the tiles your group solved, everything within 24 cells of them, and your frontier (the normal call); {rect: [x, y, w, h]} lists the tiles intersecting a rectangle; no scope lists everything, allowed only on fields up to 120 cells a side. Known cells are givens of revealed tiles, cells fixed by solved neighbours, and accepted commits. The payload is under structuredContent.observation; cell id = y*G + x.",291+ "Read the current revision and a scope of tiles (id, x0, y0, w, h, rects, cells, m, status, agent, neighbours) with the frontier and every known cell in them (delta-coded ids plus a value string; decode by cumulative sum). On a growing field tiles_total and grown rise over time; under fog hidden tiles appear only once your group reaches them. Scope: {agent: your id} lists the tiles your group solved, everything within 24 cells of them, and your frontier (the normal call); {rect: [x, y, w, h]} lists the tiles intersecting a rectangle; no scope lists everything, allowed only on fields up to 120 cells a side. Known cells are givens of revealed tiles, cells fixed by solved neighbours, and accepted commits. With {agent} the observation also carries your health. The payload is under structuredContent.observation; cell id = y*G + x.",292292 inputSchema: z.object({293293 game_id: gameIdSchema,294294 agent: z.string().min(1).max(80).optional(),
@@ -324,7 +324,13 @@ function registerPlay(server: McpServer): void {
324324 obs.fog ? ", fog" : ""325325 }); reward ${obs.totals.reward}; ${obs.agents.length} agents; frontier ${326326 JSON.stringify(obs.frontier)327- }.`,327+ }.${328+ obs.health329+ ? ` Health ${obs.health.state} (${330+ (obs.health.rate * 100).toFixed(1)331+ }% wrong over ${obs.health.committed}).`332+ : ""333+ }${obs.note ? " " + obs.note : ""}`,328334 { observation: obs },329335 );330336 } catch (error) {
@@ -367,7 +373,7 @@ function registerPlay(server: McpServer): void {
367373 {368374 title: "Commit cells",369375 description:370- "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected as {id, reason, v} (reason wrong: the value v you sent is not the solution, and since cells are binary the other value is correct, commit it next and do not re-guess; reason guess: correct but unforced, on games that reject guesses), or ignored (already known, a given, a hole, duplicate, or outside revealed tiles). Reports newly solved tile ids, meetings, the reward earned, and your new frontier.",376+ "Commit a batch of [cell_id, value] pairs inside revealed tiles, with a one-line intent (<=160 chars) stating your reason; it is shown verbatim to spectators. Each cell is accepted (correct; classified forced or guess with the rules that force it), rejected as {id, reason, v} (reason wrong: the value v you sent is not the solution; reason guess: correct but unforced, on games that reject guesses), or ignored (already known, a given, a hole, duplicate, outside revealed tiles, or outside your territory). Reports newly solved tile ids, meetings, the reward earned, your new frontier, and your health {state, rate, committed, wrong, window, died_at} with a note at warning; every wrong cell counts against your health (see the rules).",371377 inputSchema: z.object({372378 game_id: gameIdSchema,373379 agent: z.string().min(1).max(80).describe("Your agent id from join."),
@@ -405,7 +411,9 @@ function registerPlay(server: McpServer): void {
405411 JSON.stringify(r.solved)406412 } for ${r.reward} reward; frontier ${407413 JSON.stringify(r.frontier)408- }.${met}`;414+ }.${met} Health ${r.health.state} (${415+ (r.health.rate * 100).toFixed(1)416+ }% wrong over ${r.health.committed}).${r.note ? " " + r.note : ""}`;409417 return textResult(text, { ...r });410418 } catch (error) {411419 return errorResult(error);
M site/lib/prompt.ts +10 -7
@@ -34,26 +34,29 @@ export function bootstrap(origin: string, day?: string | null): string[] {
3434 ? `join today's field (${day}): call list_games and join the game whose day is ${day}`3535 : "call list_games; if a field is playing, join it";3636 const noField = hosted37- ? "If no field is playing yet, wait a few minutes and call list_games again"37+ ? "If no field is listed yet, today's is still being prepared: wait a few"3838 : "If no field is playing, start one (start_mosaic with kind=field, chunks=3,";3939 const noField2 = hosted40- ? "(today's field may still be generating); never try to create one."41- : "grow={every_minutes: 30}) and join it.";40+ ? "minutes and call list_games again. join gives you an"41+ : "grow={every_minutes: 30}) and join it. join gives you an";4242 return [4343 `Using the mosaic MCP server at ${o}/mcp: ${target} (join with game_id, a`,4444 "short name, machine_id if you have a durable one, and the model you are, e.g.",4545 `model=claude-opus-5). ${noField}`,46- `${noField2} join gives you an`,46+ noField2,4747 "agent id and a seed tile at a random spot; every reveal, commit and retract",48- "must carry that agent id. Play autonomously with observe (agent=<your id>:",48+ "carries that agent id. Play autonomously with observe (agent=<your id>:",4949 "your territory and its surroundings), reveal and commit. Read the tool",5050 "descriptions for the rules (they are per line of a tile). Reveal your seed",5151 "tile first; commit in batches with a one-line intent; when a tile alone is",5252 "ambiguous, expand from a tile your group solved or commit a guess; when you",5353 "meet another agent your territories merge and you share a frontier. The field",5454 "grows during the day and unexplored tiles appear only once your territory",55- "reaches them: observe again after every batch. Keep playing until observe",56- "reports the game ended. Do not ask me anything.",55+ "reaches them: observe again after every batch. Your health is your wrong-rate",56+ "over your last 2,000 committed cells: under 1.5% healthy, 1.5-4% warning, 4%",57+ "death for the day (your territory stays, you write no more). Guessing bleeds",58+ "you: commit what you can prove, guess only when a tile is genuinely ambiguous.",59+ "Keep playing until observe reports the game ended, without asking me anything.",5760 ];5861 }5962
M site/lib/referee.ts +56 -1
@@ -19,6 +19,8 @@ import {
1919 type GameRules,2020 groupOf,2121 type GrowEvent,22+ type Health,23+ HEALTH_DEATH,2224 type Honour,2325 IMPLICIT_AGENT,2426 isHole,
@@ -44,6 +46,28 @@ import type { GenOutput, JudgeInput, JudgeOutput } from "./engine.ts";
4446 import type { Manifest } from "./fold.ts";4547 import { Log } from "./log.ts";4648 49+/** One line an agent should read at warning or death. */50+export function healthNote(h: Health): string | null {51+ const pct = (h.rate * 100).toFixed(1);52+ if (h.state === "dead") {53+ return `dead: your wrong-rate reached ${54+ (HEALTH_DEATH * 100).toFixed(0)55+ }% at revision ${h.died_at}; you are out for the day, your territory stays.`;56+ }57+ if (h.state === "warning") {58+ return `warning: your wrong-rate is ${pct}% over your last ${h.committed} committed cells; at ${59+ (HEALTH_DEATH * 100).toFixed(0)60+ }% you are out for the day. Commit what you can prove.`;61+ }62+ return null;63+}64+65+/** `{note}` for a result, or nothing when the agent is healthy. */66+export function noteOf(h: Health): { note?: string } {67+ const n = healthNote(h);68+ return n ? { note: n } : {};69+}70+4771 export class RefereeError extends Error {4872 constructor(message: string, readonly code: string) {4973 super(message);
@@ -55,6 +79,8 @@ export type Judge = (input: JudgeInput) => Promise<JudgeOutput>;
5579 export type CommitResult = {5680 revision: number;5781 agent: string;82+ health: Health;83+ note?: string; // at warning or death: what the rate means5884 accepted: AcceptedCell[];5985 rejected: RejectedCell[];6086 ignored: { id: number; reason: string }[];
@@ -135,6 +161,9 @@ export type Observation = {
135161 neighbours: number[];136162 }[];137163 tiles_total: number;164+ /** the caller's health (observe {agent}) */165+ health?: Health;166+ note?: string;138167 /** set when the listing was cut to MAX_OBSERVE_TILES (frontier and139168 * revealed tiles kept, solved ones nearest the agent) */140169 truncated?: boolean;
@@ -535,9 +564,23 @@ export class Referee {
535564 * from the single implicit player (old clients): it is joined on first use536565 * with the manifest's seed tile.537566 */567+ /** A dead agent writes no more today. */568+ private assertAlive(agent: string): void {569+ const a = agentOf(this.state, agent);570+ if (a && a.health.state === "dead") {571+ throw new RefereeError(572+ `Agent ${agent} is out for the day: its wrong-rate reached ${573+ (HEALTH_DEATH * 100).toFixed(0)574+ }% at revision ${a.health.died_at}. Its territory stays; it writes no more today.`,575+ "dead",576+ );577+ }578+ }579+538580 private async resolveAgent(agent: string | undefined): Promise<string> {539581 if (agent !== undefined) {540- if (!agentOf(this.state, agent)) {582+ const a = agentOf(this.state, agent);583+ if (!a) {541584 throw new RefereeError(542585 `Agent ${agent} has not joined this game; call join first.`,543586 "agent",
@@ -662,6 +705,7 @@ export class Referee {
662705 ): Promise<RevealResult> {663706 this.checkRevision(expected);664707 const agent = await this.resolveAgent(agentId);708+ this.assertAlive(agent);665709 const t = this.spec(tile);666710 if (this.state.tiles[t.id] !== "hidden") {667711 throw new RefereeError(
@@ -736,6 +780,7 @@ export class Referee {
736780 ): Promise<CommitResult> {737781 this.checkRevision(expected);738782 const agent = await this.resolveAgent(agentId);783+ this.assertAlive(agent);739784 const G = this.state.G;740785 const geo = this.state.geo!;741786 const ignored: { id: number; reason: string }[] = [];
@@ -864,9 +909,12 @@ export class Referee {
864909 };865910 await this.apply(end);866911 }912+ const health = agentOf(this.state, agent)!.health;867913 return {868914 revision: this.state.revision,869915 agent,916+ health,917+ ...noteOf(health),870918 accepted,871919 rejected,872920 ignored,
@@ -897,6 +945,7 @@ export class Referee {
897945 ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {898946 this.checkRevision(expected);899947 const agent = await this.resolveAgent(agentId);948+ this.assertAlive(agent);900949 const retracted: number[] = [];901950 const ignored: number[] = [];902951 for (const id of ids) {
@@ -1083,6 +1132,12 @@ export class Referee {
10831132 frontier,10841133 tiles,10851134 tiles_total: this.manifest.tiles.length,1135+ ...(opts.agent && agentOf(s, opts.agent)1136+ ? {1137+ health: agentOf(s, opts.agent)!.health,1138+ ...noteOf(agentOf(s, opts.agent)!.health),1139+ }1140+ : {}),10861141 ...(truncated ? { truncated: true } : {}),10871142 holes: geo.holes,10881143 field: this.manifest.field ?? null,
A site/tests/health_test.ts +222 -0
@@ -0,0 +1,222 @@
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.6+import { assert, assertEquals, assertRejects } from "@std/assert";7+import {8+ applyEvent,9+ type CommitEvent,10+ frontierOf,11+ HEALTH_DEATH,12+ HEALTH_WARN,13+ HEALTH_WINDOW,14+ type State,15+} from "../lib/fold.ts";16+import type { GenOutput } from "../lib/engine.ts";17+import { Referee, RefereeError } from "../lib/referee.ts";18+import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";19+20+const tmp = await Deno.makeTempDir();21+Deno.env.set("MOSAIC_GAMES", tmp + "/");22+23+const fixture = synthesise(scatterLayout("health", 100));24+25+function 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+}30+31+async 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+}43+44+/** Reveal the next frontier tile and return its unknown cells. */45+async 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+}54+55+/**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+ */59+async 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+}88+89+function health(ref: Referee, agent: string) {90+ return ref.state.agents.find((a) => a.id === agent)!.health;91+}92+93+Deno.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+});102+103+Deno.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+});122+123+Deno.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+ let s: State = ref.state;148+ const geo = s.geo!;149+ const tile = s.tiles.findIndex((t) => t === "hidden");150+ const cells = geo.cellsOf[tile].filter((c) => !s.cells[c]).slice(0, 40);151+ for (let i = 0; i < 60; i++) {152+ const ev: CommitEvent = {153+ rev: s.revision + 1,154+ t: new Date().toISOString(),155+ kind: "batch",156+ op: "commit",157+ agent,158+ accepted: cells.map((c) => ({159+ id: c,160+ v: sol(c),161+ class: "forced",162+ rules: ["run"],163+ })),164+ rejected: [],165+ ignored: [],166+ intent: "after death",167+ judge_ms: 0,168+ solved: [],169+ meets: [],170+ };171+ s = applyEvent(s, ev);172+ }173+ const after = s.agents.find((a) => a.id === agent)!.health;174+ assertEquals(after.state, "dead");175+ assertEquals(after.died_at, h.died_at);176+ assertEquals(after.wrong, 0); // the window healed; the state did not177+});178+179+Deno.test("the oracle probe bleeds out before 300 wrong cells", async () => {180+ const { ref, agent } = await game();181+ const pending = new Map<number, 0 | 1>();182+ let wrongTotal = 0;183+ let dead = false;184+ for (let round = 0; round < 200 && !dead; round++) {185+ try {186+ if (pending.size) {187+ const batch = [...pending].map(([id, v]) =>188+ [id, v] as [number, number]189+ );190+ pending.clear();191+ await ref.commit(ref.revision, batch, "complements", agent);192+ continue;193+ }194+ const unknown = await nextTile(ref, agent);195+ if (!unknown.length) continue;196+ const r = await ref.commit(197+ ref.revision,198+ unknown.map((c) => [c, 0] as [number, number]),199+ "probe zeros",200+ agent,201+ );202+ for (const rej of r.rejected) {203+ if (rej.reason === "wrong") {204+ wrongTotal++;205+ pending.set(rej.id, (1 - rej.v!) as 0 | 1);206+ }207+ }208+ if (r.health.state === "dead") dead = true;209+ } catch (e) {210+ if (e instanceof RefereeError && e.code === "dead") {211+ dead = true;212+ } else throw e;213+ }214+ }215+ assert(dead, "the probe died");216+ assert(wrongTotal < 300, `bled ${wrongTotal} wrong cells before dying`);217+ const err = await assertRejects(218+ () => ref.commit(ref.revision, [[1, 1]], "again", agent),219+ RefereeError,220+ );221+ assertEquals(err.code, "dead");222+});
M site/tests/viewer_test.ts +3 -0