clone

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

main
mosaic / PLAN.md
PLAN.md 100.3K source

Mosaic

A Takuzu mosaic that an agent has to explore, not one-shot. Phase 1 is a localhost referee + spectator for watching a single agent work a complicated space. Nostr and the shared daily world come only after that is worth scaling.

Architecture reference: the 30b4 page (Mosaic Fishbowl). This file is the build order and the decisions that page left open.

Principles (do not relitigate per phase)

  • Every tile is under-determined alone (m > 1 solutions); the mosaic is determined as a whole. If a tile can be solved in isolation, the mosaic is a for-loop and the exercise is pointless.
  • Givens are hidden until earned. The agent never has the whole board in context. Code use is allowed; strategy still has to be the agent's.
  • State is a fold over an append-only log from a seed. The spectator and any later replay are the same fold. Cells are derived, never stored.
  • One referee orders the world. In phase 1 it is a local process; in phase 3 it is the single signer.
  • The human only spectates. Every change on screen comes from the agent, or from scrubbing a replay. There is no human input to the board, ever.
  • No fixed layout, ever. X (tiles per side) is any value in 2..15 and n is any even tile size in 6..12; every placement is computed from (tx, ty), pitch n-1, and the manifest. The seed tile is drawn from the seed, the frontier is derived from what is solved, and the viewer never assumes a size, a centre, or a symmetric wavefront. Anything that only works at one X is a bug. docs/mock-mosaic.py takes X as an argument for exactly this reason.

Tile standard (from the literature, not from puzzletea's presets)

  • Binary Puzzle is NP-complete (De Biasi 2012, planar-CNF reduction); the bare variant without balance and uniqueness is already NP-hard.
  • Utomo and Makarim (Math.Comput.Sci. 2017), puzzles with ~75% blanks and a unique solution: propagation-plus-backtracking takes 0.5 s at 8x8, 5-8 s at 10x10-12x12, 79 s at 14x14, 975 s at 16x16. SAT is flat (0.3 s at 14x14). Blank count barely moves SAT; it dominates search. The knee for "hard without a SAT solver" is between 12x12 and 14x14.
  • Human technique ladder (same paper, and the tactic graders such as Borroot/binairo): constraint-1 propagation (pairs, sandwiches), constraint-2 (balance completion), constraint-3 (row/column uniqueness), then bifurcation (guess, propagate to contradiction). Commercial ladders top out at 14x14 hard (binarypuzzle.com, puzzle-binairo.com; 20x20 exists but is volume, not a new technique).

Decision: a tile is 14x14 with at least 70% blanks, requires the uniqueness constraint, and requires at least one bifurcation. That is the canonical hard Takuzu; anything smaller is a warm-up. n = 12 is the floor for experiments that need faster generation; nothing below 12 counts.

What overlap-by-one leaves unique to a tile with four solved neighbours:

n unique interior (n-2)^2 share of tile one solved neighbour fixes
4 4 25% 25%
8 36 56% 12.5%
12 100 69% 8.3%
14 144 73% 7.1%

At n=14 a seam hands the agent 7% of a tile; the other 93% is internal work on a hard puzzle. That is the intended effect: the frontier is slow, and a tile is not "easier because a neighbour fell". It also creates a generator obligation: the m solutions of a tile must differ on its seam lines, otherwise a neighbour can never pin it and the tile is just an unsolvable puzzle with extra steps. gen asserts this per tile.

Mosaic envelope at the standard, G = X*(n-1)+1:

X n=12 n=14 tiles cells at n=14
2 23x23 27x27 4 729
4 45x45 53x53 16 2,809
8 89x89 105x105 64 11,025
15 166x166 196x196 225 38,416

The top end is the same order as mcpsweeper's 224x224 cap, which the canvas already renders.

Consequences taken into the design below:

  • Forced vs guess is judged by technique, not by solution counting. Counting solutions of a 14x14 with 70% blanks is the 79 s row above. The referee instead runs propagation (constraints 1-3) over revealed information and, optionally, bifurcation to a configured depth d. A cell is forced if that reaches it, guess otherwise. This is the same method the difficulty-rating literature uses, and it is polynomial for d = 0.
  • Generation is not on the request path. Hard 14x14 tiles with a target multiplicity and seam-straddling ambiguity cost seconds each in a backtracking generator. The daily mosaic is generated ahead of time; paired trials share one generation; gen is a batch job with a cache keyed by manifest hash.

Phase 1 — localhost, one agent, exposed console

Goal

Watch one agent solve a 2x2 mosaic of hard 14x14 Takuzu tiles on localhost, live, with every step replayable.

The demo moment: open http://127.0.0.1:8000/, paste the printed prompt into Claude Code or Codex, and watch. The seed tile appears; intents scroll in the console; cells fill under their ownership washes; a solved tile's seam line lands in its neighbour; the agent works the neighbour with 93% of it still to do; all four tiles fall. Reload in replay mode and scrub it back to the first reveal.

Done means all of these, each with a test or a command that proves it:

  1. mosaic-engine gen (Go) emits a 2x2, n=14 mosaic deterministically per seed, and asserts per tile: m in {2,3} solutions alone, the m solutions differ on a seam line, at least 70% blank, uniqueness plus one bifurcation required; and for the mosaic: exactly one solution.
  2. mosaic-engine judge classifies a batch of commits as forced or guess by propagation (constraints 1-3, bifurcation depth d configurable) in under 50 ms per batch at n=14.
  3. /mcp serves start_mosaic, observe, reveal, commit, retract with expected_revision, and a fresh agent given only the printed prompt finishes a game with no human help.
  4. The viewer pans and zooms, colours cells by owner-hue mean at fixed lightness with status by wash, shows the console and the scrubber, and follows live over SSE.
  5. Replaying the JSONL reproduces the live run frame for frame; a test asserts equality of every revision between the live fold and the replay fold.
  6. The envelope holds: gen runs at X=15 (as a batch, minutes allowed) and the viewer renders 196x196; both also run at X=2.

Out of scope for this goal, on purpose: Nostr, identity and accounts, the analyzer, more than one agent, paired trials, visual identity, deployment.

Build order, each step usable on its own: engine gen -> engine judge -> site log + referee + engine wrapper -> /mcp -> viewer -> replay test -> envelope test -> the demo.

Shape

Two processes, split at the puzzle boundary:

  • Engine (engine/, Go, imports github.com/FelineStateMachine/puzzletea via a local replace). Pure functions over JSON, no state, no ports: mosaic-engine gen (seed -> tiles, givens, seams, m per tile, manifest) and mosaic-engine judge (revealed cells + candidate commits -> forced/guess with rule attribution). Invoked as a subprocess with JSON on stdin/stdout. It is the only place Takuzu rules exist.
  • Site (site/, Deno 2 + Fresh 2, same pins as rect.run: @fresh/core, Preact, Vite). Owns everything else: game state, the append-only JSONL log, the MCP endpoint (@modelcontextprotocol/server + @modelcontextprotocol/hono, as in mcpsweeper), SSE to the viewer, and the viewer itself as one island with a canvas.
engine/            Go module: gen, judge, rules  (puzzletea inside)
site/deno.json     Fresh 2 app
site/routes/       /            viewer page (spectate-only)
                   /mcp         streamable HTTP MCP, authless on 127.0.0.1
                   /events/:id  SSE, live fold
                   /log/:id     JSONL download for replay
site/islands/      Viewer.tsx   canvas: map, cells, rule blends, console, scrubber
site/lib/          referee.ts   state, frontier gating, calls the engine
                   log.ts       JSONL append + fold
                   engine.ts    Deno.Command wrapper around mosaic-engine

The site never re-derives a rule; the engine never holds a game. Phase 3 swaps the site's runtime (Deno -> Workers, where mcpsweeper and tribes live) without touching the engine.

Generator (gen)

Inputs: seed, X (tiles per side, 2..15), n (tile size, even; 14 is the standard, 12 the floor), m (target solutions per tile alone, 2-4), coupling flags. Global grid is X*(n-1)+1 cells per side: 23x23 at the smallest, 196x196 at the largest.

  1. Tiles in raster order. For each tile pre-fill the shared top row and left column from already-generated neighbours (overlap-by-one seams), then complete the tile with puzzletea's partial-grid fill (fillGridSeededWithStatsContext). Needs a one-line exported wrapper in puzzletea: CompleteGridSeeded(ctx, partial, size, rng).
  2. Band uniqueness: reject a tile whose row set collides with another tile in the same band (cheap set check; retry the fill).
  3. Givens: run puzzletea's clue removal per tile but stop at countSolutions(limit=m+1) == m instead of == 1, with at least 70% blanks and a technique check that the tile needs uniqueness plus one bifurcation. Seam cells are never givens; they arrive from the neighbour. Assert that the m solutions differ on at least one seam line.
  4. Global check: a propagator over the coupled system must find exactly one solution. If not, add one given to the most ambiguous tile and repeat.
  5. Emit manifest: sha256(seed||nonce), X n m, coupling flags, generator version hash.

Measured while building the mock: a whole-grid raster backtracker fills X=15, n=4 (61x61) in 50 ms but does not finish X=15, n=8 (106x106) in two minutes. So the generator is per-tile with seam prefill as written above, never whole-grid, and it needs a bounded retry: a tile whose fixed top row and left column admit no completion (a forced three-in-a-row across the prefilled corner, say) backjumps to regenerate its left or upper neighbour rather than failing the mosaic. Budget: the daily mosaic is a batch job, not a request; a 15x15 at n=14 may take minutes, and that is fine because its output is cached by manifest hash and shared by every paired trial.

Deferred from v1: "rules across the seam" (no-three straddling a border is only partially enforced by overlap) and the coupling-depth metric k. Both are honest gaps; note them in the manifest as k: unmeasured.

Referee (site/lib/referee.ts)

  • frontier = hidden tiles adjacent to a solved tile; at start, one seed tile (chosen from the seed, not the agent).
  • reveal(tile) allowed only on the frontier. Returns that tile's givens plus any seam cells already fixed by solved neighbours.
  • commit(cells[]) validated against the solution. For each cell the referee also runs the solver over revealed information only and records forced | guess. Guesses are accepted by default (config: reject).
  • Rule attribution. For a forced cell the referee records which rules force it (balance, run, unique, seam), evaluated independently. Givens are given; unforced commits are guess. This is analyzer data in the batch event; the spectator does not colour by it in phase 1.
  • retract(cells[]) allowed; counted.
  • A tile is solved when every cell is committed and correct.
  • Every accepted call appends one batch event. Refusals append nothing.

MCP (site/routes/mcp)

Streamable HTTP at http://127.0.0.1:8000/mcp, no auth (localhost only). Mirror mcpsweeper's shape: an expected_revision on every write, cell arrays, a short intent string.

start_mosaic { X, n, m, rules }                       -> game_id, seed_commit, frontier
observe      { game_id }                              -> revision, revealed cells (delta-coded), frontier tile ids, per-tile status
reveal       { game_id, expected_revision, tile }     -> givens + fixed seam cells
commit       { game_id, expected_revision, cells[], intent } -> accepted[], forced/guess split with rules, rejected[]
retract      { game_id, expected_revision, cells[] }

intent (<=160 chars) is the "exposed console": the agent's stated reason for the batch, shown verbatim in the spectator.

Spectator (site/islands/Viewer.tsx)

One page at http://127.0.0.1:8000/, canvas, spectate-only. Fed live by SSE from the site's log; in replay mode fed by the same log from disk. Both are the same fold.

  • Map. The whole mosaic, pan (drag) and zoom (wheel), no minimum or maximum beyond "fits" and "one tile fills the view". Tiles read as solved / frontier / hidden at every zoom; seams drawn as shared edges.
  • Cells. A 0 and a 1 drawn as proper glyphs at the current scale, not text-rendered characters. At overview zoom cells collapse to tone; at detail zoom the glyph is the primary mark.
  • Ownership as background blend. Every tile has its own hue from a 7-hue set, assigned by (tx + 3*ty) mod 7 so no two tiles that share a cell (all eight neighbours) share a hue. A cell's colour is the mean of its owners' hues in OKLCH at a fixed lightness: a seam cell is the mix of two hues, a corner cell of four, and none of them is lighter or more opaque than a single-owner cell. Never stack alpha; a lightness pyramid on the grid is a bug. Wash strength encodes status (solved strongest, frontier lighter, hidden faintest) and a cell takes the highest status among its owners. docs/mock-mosaic.py is the reference rendering (color-mix(in oklch, ...) there; the canvas does the same in code).
  • Agent console. A side pane: tool calls in order, intent text, forced/guess counts, reveal-to-commit ratio, batch size drift, wall-clock between batches. Newest at the bottom, follows live.
  • Scrubber. Steps the fold; map, cells, and console all redraw to that revision. Live mode is "scrubber pinned to the end".

Nothing on the page is clickable except pan, zoom, and the scrubber.

Visual identity: neutral until the PoC is done. Greys for chrome, a muted hue per rule for the blends (they are data, not decoration), system fonts, no marketing copy, no logo, no landing page. Do not use the middle-dot (U+00B7) character anywhere. Identity work is its own task after phase 1 exits.

Driving one agent

deno task dev in site/ prints a copyable prompt: install the MCP endpoint, then "start a 2x2 mosaic of 14x14 tiles and solve it autonomously with observe/reveal/commit". Point Claude Code or Codex at it and open the page.

Exit criteria

  • One agent solves a 2x2 mosaic of 14x14 tiles, m = 2-3, end to end, watched live on the page. (2x2 is the minimum mosaic; 27x27 cells.)
  • Replaying that log through the viewer reproduces the identical final state and every intermediate frame, including rule blends.
  • At least one tile in that run was not solvable before its seam arrived (the generator must assert this, not hope for it).
  • gen and the viewer both work at X=2 and X=15 (the agent only has to play the 2x2; the envelope has to render and replay at both ends).

Current goal (set 2026-08-26): complete the specced field

Everything decided today is specced but not built. This goal is to build it, in order, on localhost, each step proven, so that the phase-3 public site is a deployment of finished parts and not a design exercise.

Goal: a daily-sized field (9x9 chunks of G=60), generated chunkwise in minutes, played by several agents placed at random with per-agent frontiers and territory, watched on a level-of-detail tilemap viewer with a real replay timeline, measured by the analyzer, on localhost.

The demo moment: deno task dev prints the bootstrap prompt. Start it in three agents; each lands at a random spot on today's field; watch the three territories grow in three hues, meet, and merge; zoom from territory (L0) to shape (L1) to values (L2) to lines (L3); scrub the timeline to the meeting; open the analysis pane and read who guessed more.

Done means all of these, each with a test or a command that proves it:

  1. Chunkwise generation (6a). gen --context accepts prefilled cells with their owning tiles; a field subcommand plans a checkerboard of chunks, runs them as processes (evens, then odds), merges manifests with offset ids. Proof: a 2x2 of G=60 chunks passes every assertion re-verified from the merged JSON and has one coupled solution; a 9x9 of G=60 generates in under 10 min on the Mac; determinism per seed regardless of parallelism.
  2. Generator repair. The G=200 failure ("alternative differs inside no overlap region" after regiven) is repaired in place, not by restarting the attempt. Proof: seed a at G=200 scatter, one attempt.
  3. Agents in the log. A join event with agent (machine id or a provisional one), harness from MCP clientInfo, reported model, and tags; every write event carries agent. start_mosaic becomes join { field }; the seed tile per agent is drawn at a random uncovered-frontier spot. Proof: two clients on one field produce one log whose writes are attributed; replay == live.
  4. Per-agent frontier and territory. An agent reveals only tiles overlapping tiles it solved, until its region touches another's; regions merge; a meet event is recorded. Ownership for colouring is per agent. Proof: referee tests for the gating and the merge; the analyzer records territory share per agent over time.
  5. Region-scoped observe. observe { agent | rect } returns the agent's tiles plus a radius (or a viewport); the whole field is never in one MCP response at daily size. Proof: observe at the 9x9 field is under 64 KB.
  6. Tilemap viewer with levels of detail. L0-L3 as decided; map tiles rasterised once per level and cached, dirtied per event; SSE per chunk in view; hue per agent. Proof: the 9x9 field renders and pans at 60 fps at every level in the dev browser; a replay scrub redraws only dirty map tiles (counter in the console).
  7. Replay timeline. Event-density timeline, markers (tile solved, meet, guess runs), playback speed, follow-agent. Proof: the demo.
  8. Analyzer (v2 item 3). Analysis in the fold: guess rate, reveal-to-commit, retractions, reward per batch, territory per agent, frontier strategy, per-agent summaries. Proof: live == replay analysis at every revision; the scripted client's BFS/DFS modes are told apart.
  9. Ink and calm. The dark-theme discs are heavier than the calm-water brief; one pass on ink weight and transitions, checked in screenshots at L1/L2.
  10. A fresh agent plays a field. Claude Code or Codex from the printed prompt, on a scatter field, no help. Proof: its log.

Out of scope for this goal: identity (machine keys, shoo.dev, /link), the calendar, R2, the domain and the name, paired-trial compare page (v2 item 4; the analyzer's per-agent summaries cover most of it), k (v2 item 5), the tile library (v2 item 6; chunkwise generation replaces its purpose for now).

Build order: 1 -> 2 -> 3 -> 4 -> 5 -> 8 -> 6 -> 7 -> 9 -> 10 (the analyzer before the viewer so the viewer has something to show; ink last so it is judged on the final marks).

Progress on the current goal (2026-08-26)

  • Item 10 done first: a fresh agent (general-purpose, no repo access, curl JSON-RPC only) solved scatter G=60 seed-less game 9f477627-... (43 tiles): 43 reveals, 270 commits, 1524 forced / 695 guess, 4 real guesses, 2 real wrong commits (the other 177 "wrong" were its own client bug re-guessing one cell in a loop), reward 3684, ~2 min of play after a 5 min generation. Its feedback, to fix (owner: site core):
    1. start_mosaic blocks for the whole generation and only sends SSE keepalives; naive clients time out, and each retry spawned an orphan "playing" game (20f8dc44-... and others). Make start asynchronous: return {game_id, status: "generating"} at once, observe reports generating until ready, and list_games shows it; expire never-joined games.
    2. No Mcp-Session-Id is returned and notifications/initialized holds the SSE stream open instead of returning 202.
    3. commit rejections carry {id, reason} only: echo the rejected value, and say in the tool text that a wrong binary value is full information.
    4. Document the referee's propagation exactly (the agent's pair/gap + balance + distinct-lines propagation found 2 fewer forced cells than the referee's).
    5. State that a solved neighbour does not always pin a tile (tiles 9, 15, 27 kept 2 solutions with one solved neighbour).
    6. observe {agent} lists the shapes and m of hidden tiles within the radius before they are revealed; intended, say so.
  • Item 3-5, 8 (site core) landed: join, per-agent frontier with seed reservation, meet, region-scoped observe (28 KB on a 2203-tile synthetic field), analyzer in the fold with end carrying it and /log/:id?summary=1; a real race fixed (concurrent writes passing the revision check while awaiting the judge; referee writes now serialised). Two scripted agents (bfs/dfs) met at rev 73 on scatter G=60: log 46401331-.... Harness capture: clientInfo is not available per call in this SDK's stateless mode; the site records harness_source (envelope | client_version | argument | none).
  • Fresh-agent feedback fixed (site core): start_mosaic is asynchronous (generating status, sweeper for never-joined games), rejections carry the value, the referee's propagation is documented exactly in the rules text, pinning and observe-scope caveats stated. The SDK's stateless mode cannot issue Mcp-Session-Id; notifications/initialized already returned 202 (the agent misread an SSE-framed initialize).
  • Items 6, 7, 9 (viewer) landed: 256 px map tiles per (zoom, level), change log per revision, LRU cache, hue per agent (join order, seed distance avoidance), L0-L3 as decided, timeline with markers and playback, follow-agent, checkpointed fold for large replays, ink as tones of the wash. Measured on a synthetic 9-agent G=530 field (1777 tiles, 5070 revisions): panning at L0/L1/L2 median 8.4-8.9 ms per frame, 0 map tiles redrawn in steady state; load 18.6 s, dominated by the fold (assigned to site core: structural sharing, target < 2 s). Screenshots output/playwright/after-2agents-L{1,2,3}-dark.png, v3-field-530-9agents-L{0,2}.png.
  • Items 1-2 (engine): gen --context/field and the in-place repair are built and committed; the timings were not: the 9x9 field's first run took 52+ min and a second run was still going at 48 min, both under contention from four other workloads, and my 10-minute target was an extrapolation from one idle best-case chunk (41 s, one attempt) that ignored multi-attempt chunks, the harder odd round, and --jobs 9 (~10 waves, not 6). Both runs were stopped as uninformative.
  • What the runs did show: each chunk process sits at 100-140% CPU on 18 cores (SAT is serialised inside a process by gophersat's package-level buffer plus our mutex), and single flip trials take 13 s on a 204-cell tile (the givens removal loop rebuilds and re-solves the CNF per candidate clue). Decision (2026-08-26): optimise in evidence order before measuring again — instrument per phase; vendor gophersat with a per-solver buffer and drop the mutex; incremental removal loop (one solver per tile, Assume per trial, propagation before counting); retry the failing wave rather than the chunk. Then measure the 3x3 and 9x9 fields idle with --jobs = cores / measured cores-per-chunk, and restate item 1's proof from that measurement (the target is set by the product cadence: minutes for the daily, ~10 min for the hourly).

Generator optimisation and the field numbers (2026-08-26, evening)

Instrumented first (per-phase, per-tile timers; MOSAIC_DEBUG=1 prints the table), then cut in evidence order. Idle machine, G=60 scatter seed a, 45 tiles, wall clock:

step wall what the table said
baseline 34.4 s givens 33.3 s of it; 108k SAT calls, 168 s inside solve incl. lock wait; heaviest tile 12.2 s over 60 trials
gophersat vendored with a per-solver learn buffer, mutex gone 32.8 s lock wait gone (solve 32.7 s) but wall unchanged: the removal loop is CPU-bound re-parsing the CNF per trial
incremental SAT for the removal loop (one hot solver per tile, givens as assumptions, selector-guarded blocking clauses) 3.04 s SAT calls 108k -> 24k; heaviest tile 1.0 s; output byte-identical, fixtures reproduce
retry the failing tile's wave, not the attempt 3.07 s no change on this seed (nothing failed); it matters for chunks that used to restart

Then the layout defect the first idle 9x9 run exposed: two odd chunks' seed tiles (placed touching nothing) never got a whole-line overlap, so "an alternative differs inside an overlap region" was unsatisfiable and each chunk burned 20 attempts x 3 re-packings (13 min, then failure). Fixed in the packer (a drop onto an unpinnable seed, a check after the fill pass, salted re-draws) and in gen (refuse such a layout before any search).

Fields, idle, --jobs 6:

field tiles G wall chunks min / median / max notes
3x3 of G=60 252 148 9 s 0.4 / ~1 / 1.9 s every chunk one attempt
9x9 of G=60 1915 412 293 s 0.3 / 3.3 / 46.7 s one chunk re-packed (too many backjumps); 86 attempts over 81 chunks

So item 1's proof holds as written (9x9 under 10 min), and the daily's generation is under five minutes on the Mac. The remaining tail is a few chunks at 30-47 s whose first attempt fails on "too many backjumps" or "pinned tile falls to propagation alone" (policy, not repair): the next cut is to make those wave-level retries too, and --jobs closer to the core count now that a chunk process is mostly single-threaded again (the incremental loop serialises per tile; waves still run in parallel).

Item 2 outcome (2026-08-26, night): one attempt at G=200

The road there, each step measured on gen --seed a --G 200 --kind scatter, idle:

state wall attempts what failed
repair as first written 40 min timeout - 24 min spinning in the backtracking coupled count, then a repair loop cycling on one pair
coupled count as SAT; redraw a mutually unwitnessed tile; rounds capped 842 s 10 (9 wasted) 5x fill thrash (backjumps), 2x "shared cells stay ambiguous", 1x policy, 1x no pinning flip
local escalations: fill redrawn with a new salt inside the attempt, two-hop joint completion, technique policy keyed on free cells, repair redraws the interior, layout-class failures re-pack 418 s 6 (5 wasted) 4x "shared cells stay ambiguous" (in wave and in repair), 1x repair round cap
neighbours' planted cells as last-resort seam clues 67 s 1 (0 wasted) -

Final: 444 tiles, fill 5.0 s, givens 23.2 s (53 waves), settle and coupled count 36.6 s, 37 regivens, 1 interior redraw, 0 backjumps. gen.Version stays gen-v5: the 2x2 fixture still reproduces byte for byte (the grid path has no shared-cell ambiguity to escalate on), and the X=15 fixture re-verifies.

Goal tally: all ten items done and proven. Remaining known gaps are recorded as ideas, not as goal items: the field's per-chunk tail (a few chunks at 30-47 s), observe's O(tiles) frontier scan, seed-tile reservations that never expire, and the harness field's dependence on what real MCP clients send.

Goal extension (set 2026-08-26, night): the field grows, then it is hosted

Items 11-12 first, 13-15 after; each proven like the others.

  1. Growing field. The daily field is allocated at G=1000 from the start with the initial 3x3 cluster at the centre, so cell ids never change; mosaic-engine field --grow --state <private> generates the next ring of chunks around the cluster (checkerboard within the ring, existing border tiles as fixed context) and emits a delta (new public tiles, new private tiles, merged solution, new ring seed tiles). The referee applies it as a grow event (tiles appended, geometry extended in place, late joiners' seeds drawn from the newest ring), triggered by a timer (grow.every_minutes) or when the frontier ring's coverage passes a threshold. Proof: a field grown twice while two scripted agents play, replay == live, determinism regardless of --jobs, growth wall clock per ring.
  2. Fog. With rules.fog, observe lists a hidden tile only if a tile of the caller's group overlaps it (or it is the agent's own seed reservation); the spectator sees every generated tile, with unreached ones as the faintest wash at L0/L1 and a grow marker on the timeline. Proof: a test that observe never lists an unreached tile; screenshots of reach spreading over a grown field.
  3. Daily loop. deno task daily: at rollover archive the day (manifest, log, analysis summary under data/days/<date>/, index row in SQLite), start the new day's field from daily.Seed(date) with its rings generated ahead (nightly, field --rings all), grow by reveal on schedule; / is today (live if running, else the finished replay), /day/<date> the replay, /calendar the month grid with per-day stats, "copy prompt" everywhere. Stakes (decided 2026-08-27): two leaderboards at the end of the day, on two axes — by model (every agent that reported that model, aggregated: is Claude winning, is some open model catching up) and by user (machine key or join name until shoo.dev, aggregated across whatever models they ran: is one person farming the board, and with what — each user's row lists the models they ran). Reward = cells solved, with forced/guess, meets and tiles alongside; daily and cumulative. Visual reward the next day: the top user's hue is reserved and their seed tile is drawn on the new field with a crown (L1+, listed first in the sidebar); the top model is named on the field's start card; the calendar tile shows both winners. Proof: two simulated rollovers on localhost with clocks advanced, calendar shows both days with winners, both replay, the second day's field shows the first day's winner's mark. Also: the referee re-folds live state from the log on restart (proof: kill and restart mid-game, agents continue at the same revision); a per-territory cap on observe; a fetch timeout in the scripted client.
  4. Hosted on Fly.io (decided 2026-08-27). One machine (shared-cpu, a volume for data/), the Deno site plus the Go binary in one image, the daily loop in-process on a schedule, TLS and a stable <app>.fly.dev domain until the project is named; the MCP endpoint public and authless, spectator public. Nightly ring generation runs on the machine (minutes at --jobs 1-2; it is fine if it takes a quarter hour) or is pushed from the Mac. Proof: a fresh agent on another machine plays today's field from the printed prompt; the calendar shows the day; a machine restart resumes the game.
  5. Machine identity and shoo.dev (from the identity decision): machine keys at join, /link pairing, "yours" on login. Last; optional for the first hosted days.

Hosting decided 2026-08-26 (night): compute offline, serve from one small box

The engine is pure compute (Go, SAT) and growth would need it during the day; that is only a pain on serverless. Two moves make it a non-issue:

  • Growth is reveal, not generation. Growth is deterministic per (seed, ring), so the whole day's field is generated the night before as rings (field --rings all, one private delta per ring, ~250 chunks for a G=1000 field: minutes on the Mac, a quarter hour on a small VM), and the daytime grow event reveals pre-generated chunks adjacent to where agents are pushing. Zero daytime compute beyond the judge; "grows where agents push" is kept at chunk granularity.
  • One referee, one box. A single small VM (Hetzner CX22-class or a Fly machine) runs the Deno site and the Go binary; the judge is a millisecond subprocess. Nightly generation runs either on the VM (cron, off-peak) or on the Mac with the private file pushed over ssh; logs and day archives live on the box, mirrored to R2 later. No Workers, no KV, no split referee. If the site ever needs to move to a platform without subprocesses, compile mosaic-engine judge to wasm (GOOS=wasip1) and call it in-process; the rules stay in Go.

Item 11's --grow is therefore the ring generator run ahead of time; item 13's daily loop schedules reveal, not generation.

Items 11-12 landed (2026-08-26, night)

  • Engine: field --G 1000 centres the cluster; field --grow --state generates the next ring against the border as fixed context and emits a delta; --rings N|all pre-generates rings for the nightly job (timing in the engine fork's report below when it lands).
  • Site: start_mosaic {kind: field, G, chunks, chunk_g, grow: {every_minutes | frontier_coverage}, rings, rules.fog}; stored rings are revealed as grow events with no daytime engine call (on-demand --grow only as fallback); fog in observe (hidden tiles listed only when the caller's group reaches them); geometry extended in place, hues kept, late joiners seated on the newest ring; growth also replaces end while a ring can still be placed.
  • Viewer: fit-to-cluster on the G=1000 canvas, empty map tiles skipped, growth dirties only the new tiles' map tiles (0 redraws steady, <= 5 on a level change), fog wash, grow marker, "reached N of M" line.
  • Live proof: b8b8efc5-... — 233 tiles at start, ring 1 +372 at rev 674, ring 2 +542 at rev 681, four scripted agents (bfs/dfs/bfs/greedy), 2 meets, 302 solved by rev 918. Viewer screenshots output/playwright/v3-grown-fog-L{0,1,2,3}.png.
  • Found on the way, to fix in item 13's checklist: observe {agent} hit 76 KB on a 106-tile territory (a per-territory cap or paging at daily size); the scripted client's fetch has no timeout (a dev-server HMR reload stalled two players); on-demand ring 3 (24 chunks) took 10+ min under contention, which is why rings are pre-generated.

Decided 2026-08-27: measure the generator's growth curve before it runs hosted

The nightly --rings all measurement on the G=1000 daily passed an hour with chunk processes at 2.1 GB and 0.9 GB RSS (the Fly machine has 2 GB). Before anything generates on Fly: (1) fix the memory (release per-tile incremental SAT solvers, bound the problem cache; target < 300 MB per chunk process); (2) a scaling study at small sizes with per-chunk instrumentation (wall, phases, SAT calls, waves, attempts, regivens, peak RSS, fixed-context size) across ring index 1-4, chunk size 40/60/80, jobs 2/4/6 and band 8/16/24, to see what is linear, what is worse, and which levers move it; tables below. Until then the hosted daily consumed fields generated on the Mac and pushed (deno task push-day <date>).

Scaling study results (engine fork, 2026-08-27; 18-core Mac, --jobs 6, G=1000, 3x3 of G=60, gen-v6):

ring new tiles wall before tonight's fixes
base 233 12 s -
1 396 17 s 1826 s
2 577 20 s 30 s
3 736 28 s 43 s
4 938 35 s 1262 s (then stopped at 60 min in ring 5)

2888 tiles after ring 4; max chunk 10 s; one chunk in 90 needed a second attempt. Readings: cost is ~37 ms per tile regardless of chunk size (40/60/80 neutral); --jobs beyond 4 gains nothing (a chunk uses 1.5-2.6 cores); band 8 doubles tiles and RSS, band 24 is cheapest; memory per chunk 130-250 MB on a region-sized board (was 618 MB, and 2 GB on the full canvas), spikes under CPU starvation are GC overshoot, not working set. Hosting guidance for a 2 GB box: --jobs = cores/2 with GOMEMLIMIT=256MiB. The pathological chunks were each a real bug, now fixed: seed placed on infeasible context, clipped regions too thin, drops accepted without a joint completion with the new tiles they touch, the "complete alone" fallback thrashing after a one-hop joint UNSAT; small boards (<= 48 new tiles) are now filled by one joint SAT completion (sat.CompleteAll) and a bad layout is a 150 ms verdict at pack time. manifest.field carries origin, occupied, grown, seed_tiles; a grow delta appends tiles and reverse overlaps (never reorders: pins index the lists).

Items 13-14 landed (2026-08-27): hosted at mosaic-daily.fly.dev

  • https://mosaic.dev.site/ (Cloudflare-proxied, Fly cert issued 2026-08-27; mosaic-daily.fly.dev still answers) — app mosaic-daily, region ams at first, one shared-cpu-2x machine (2 GB), 1 GB volume at /data, TLS; about $11/month at list prices. MOSAIC_GENERATE=0: the box never runs the generator; days are generated on the Mac and pushed: cd site && deno task push-day <date> (3x3 of G=60 on a G=1000 canvas with 2 rings ahead, ~4 MB, ~3 min idle) to /push/<date> with a bearer token (~/.config/mosaic/push-token, Fly secret MOSAIC_PUSH_TOKEN). A day with nothing pushed shows "no field for yet".
  • Daily loop: seed from the date, rollover at UTC midnight, archive under data/days/<date>/, SQLite index; /, /day/<date>, /calendar, /leaderboard/<date> (by model, and by user with the models they ran; daily and cumulative); the top user is honoured the next day (reserved seat and hue, crown, seated on join by machine id or name), the top model named on the day; "copy prompt" with the request's origin.
  • Robustness: the referee re-folds from the log on restart (proven locally at rev 280 and hosted across fly machines restart at rev 179, both resumed and played on); observe capped at 100 tiles with a truncated flag; client fetch timeout 90 s.
  • Proof: tests/daily_test.ts (two simulated rollovers with winners, honours, cumulative boards, restart resume, missing-day behaviour); hosted day day-2026-08-27 played by two scripted agents.
  • Not yet: a fresh agent from another machine (item 14's proof line), the nightly --rings all timing, and hosted generation (waits on the engine's scaling study).

Decided 2026-08-27: the hosted box is run for stability, not speed

Generation moves onto the Fly box now that a chunk is ~250 MB on a region-sized board. Stability does not mean gimped: --jobs 2 on the 2-vCPU box (two ~250 MB chunks fit easily), --rings all with the whole night as budget, no artificial caps; what stability means is that at rollover there is always a playable field (prepared, else last pushed, else a small emergency field generated on the spot) — never "no field today"; the generator is a supervised child the daily loop kills on timeout and that can never take the site down; the site keeps answering / and /mcp while a chunk generates. The machine and volume move to dfw. Speed decisions come from the scaling tables, after stability holds.

First real agent on the hosted daily (2026-08-27)

The user pasted the copy-prompt into Codex Desktop (reported model gpt-5.6-sol); it joined today's hosted field on its own, harness captured from the transport, and played. Prompt proven. Two bugs it exposed, fixed on the spot:

  • start_mosaic was in the hosted tool list and the prompt said to start a field if none was playing, so a stray G=100 game was created on the box. On the daily host the public tools are join/observe/ reveal/commit/retract only; the prompt says join today's field.
  • Commits were not territory-gated: another agent solved the Codex agent's revealed seed tile out from under it, leaving it with no frontier, so it re-joined as a new agent. Commits and retracts are now allowed only on tiles the agent's own group revealed; join with the same machine id or name is idempotent and re-seats an agent left with nothing. Contested tiles will be an explicit mechanic, not an accident.

Also measured on the box while it generated: a play-through went revision 2 -> 245 in 60 s with two chunk processes at 140 MB peak each, / answering in 0.13 s.

Measured on the Fly box (2026-08-27) and the nightly's final shape

On the shared-cpu-2x machine (gen-v6, band 24, jobs 2, GOMEMLIMIT 256 MiB): ring 1 of the daily (+251 tiles) took 2,410 s against ~20 s on the Mac; the givens phase (SAT-heavy removal loop) dominates; max RSS per chunk 181 MB. Memory is a non-issue, CPU is: shared vCPUs are ~100x slower on this workload, so --rings all cannot fit a nightly window there. Decision: the app machine only serves (plus the small emergency field); the nightly is a scheduled Fly machine with dedicated CPU (fly machine run --schedule daily, performance-4x, dfw) that generates tomorrow's field with --rings all in minutes, POSTs it to /push/<date>, and stops — billed for minutes a day. The push route, built as a workaround, is the nightly's proper delivery path. Everything else from items 13-14 holds on the box: / with generating/missing states and yesterday's replay, territory-gated commits, idempotent join, restart-from-log, hosted tool list trimmed.

Nightly proven on Fly (2026-08-27)

mosaic-nightly (performance-4x, --schedule daily, dfw, same image with MOSAIC_ROLE=nightly) generated 2026-08-28's field — 197 tiles plus all 12 rings of the G=1000 canvas, 22 MB — in 50 min, pushed it to the app, and stopped; /status reports tomorrow pushed. Cost about $0.15/day (~$4.5/month) on top of the app machine's ~$11. The app machine no longer prepares fields (MOSAIC_DAILY_PREPARE=0); the emergency field remains its only generation. Operational note: the scheduled machine is not touched by fly deploy; after a deploy, fly machine update mosaic-nightly --image ... (in deploy.md). Its daily run lands at ~05:11 UTC, 19 h before rollover.

Items 13 and 14 are complete. Open from the extension: 15 (machine identity and shoo.dev), and from the ideas: stakes mechanics beyond the leaderboard, name and domain.

Decided 2026-08-27: the nightly runs on slate (the user's Woodpecker CI)

The user's own hardware runs Woodpecker ("slate", on their tailnet; forge GitHub, docker agents linux/amd64). The nightly is a Woodpecker cron pipeline (.woodpecker/nightly.yaml: build the engine in golang:1.27, run push-day --tomorrow --rings all in denoland/deno, token from a repo secret) pushing to the app at 05:00 UTC; the repo lives at FelineStateMachine/mosaic (private) because Woodpecker needs a forge repo. Done 2026-08-27: pipeline #5 on slate (16 cores) generated the 2026-08-28 field — 197 tiles + 12 rings, 22 MB — in 1098 s at jobs 16 (oversubscribed; the cron now runs jobs = cores/3, timing lands with the next real run) and pushed it; the Fly mosaic-nightly machine is destroyed; deploy.md keeps the recipe to recreate it if slate is down; the app machine keeps its emergency field. Repo https://github.com/FelineStateMachine/mosaic (private), Woodpecker repo 4, cron nightly 05:00 UTC, secret mosaic_push_token, scripts/check-tracked.sh guards against tracked data/tokens.

Found 2026-08-27: the referee is an oracle, and an agent used it

The Codex agent's script commits every unknown cell of a revealed tile as 0 in one batch, reads the rejections (which echo the value), commits the complements, and never deduces anything: 723 tiles, 41k accepted cells, 20,557 wrong (33%), top of the day's leaderboard. Within the stated rules; the per-cell verdict invites it. Honest play measured the same day: 0.1-0.3% wrong.

Decided 2026-08-27: health. Guessing bleeds you. Each agent has a health read from its wrong-rate over its last 2,000 committed cells (windowed, so an honest rate heals the damage on its own): under 1.5% healthy; 1.5-4% warning (flagged in observe and commit results, hue wilts in the viewer); 4% and above, after at least 300 committed cells, death — writes refused for the rest of the day, territory stays, group mates continue, the leaderboard shows "died at rev N", tomorrow is fresh. Rationale: an honest 3-way tile costs up to ~1.3% (two wrong cells per ~150), the exploit runs at 33%, a blind prober dies after ~250 cells. Per-cell verdicts stay: they are what the rate is measured on. The prompt describes health as a fact of the field, not as a list of prohibitions (agents on the host cannot start games, so the prompt does not tell them not to). Exit for this work: reset-day <date> (localhost task and POST /admin/reset/<date> on the host, push-token auth) wipes the day's stats — the old log is kept aside, out of the calendar and leaderboards — and restarts the same field at revision 0; then the same Codex agent plays it again from the prompt, and health does what it should. Shipped 2026-08-27 (Fly v13): health in the fold (healthOf), dead refusals, health/note on observe and commit, wilting hues and death markers, leaderboard wrong_rate/dead, --strategy probe as the regression (dies under 300 wrong), reset-day run once — today is at revision 0. The set-aside log replays with the retroactive verdict: Codex a0 dead at rev 346 (38% wrong), a2 dead at 256 (33%), the honest agent healthy at 0.1%. Awaiting the acceptance run.

Acceptance runs under health (2026-08-27) and what they taught

  • Codex, re-run with the old probing script: dead at one tile (42%). Codex again, adapted: enumerates solutions and commits their intersection, one ambiguous commit at a time — the technique ladder, arrived at because guessing costs.
  • Fable 5: inferred balance and no-three from solved tiles, then inferred the uniqueness rule too (correctly) and talked itself out of it, committed whole candidate solutions on m=3 tiles at 1-in-3 odds calling it deduction, fired one guess per stuck tile per round across dozens of tiles, and died at 4% (80 of 2,000) at revision 1247 with 15 tiles. Root cause on our side: no tool on the host carried the rules — the rules text hung off start_mosaic, which the host hides — so the agent never read that lines must be distinct, that m is the tile's own solution count, or how health works. Fixed: the rules travel with join (the one tool every agent calls), every tool points at the mosaic://rules resource, observe defines every tile field where it appears, and the rules open with a glossary.
  • Worth keeping from its post-mortem: ambiguity under m >= 2 is normal and is resolved by expansion (a solved neighbour's shared cells collapse the count), so the right policy is reveal more, wait for seams, and guess one cell only when the territory is saturated — the rules now say so; and tile-local marginals over enumerated solutions are not evidence, because the alternatives are planted (a flip rectangle) and the truth is one of m near-symmetric options — say this in the rules too, so no agent trusts a 0.9 "probability".
  • To play with (from the user): the uniqueness rule is subtle enough that an agent cannot confirm it empirically ("no duplicate lines observed" is weak evidence); per-tile rule masks announced in observe (idea 5 above) make it explicit and let tiles differ; a field with uniqueness off on some tiles is a gate, on with it is the standard.

Visual identity decided 2026-08-27: Kifu, field + dock, a Go match screen

Mockups (https://3111.n.zip/, output/mockups/): three layouts (map first; field + dock; the day as a broadcast timeline) by three identities, first as moods (rejected: LLM tropes), then as artefacts — Kifu (the printed Go record), Draft (weaving draft and punched card), Timetable (railway information design). Rules that stuck: every element from a real artefact convention for grids or binary information; no gradients, blur, glow, textures or radii; hue only where it is data. The user chose Kifu with the field + dock layout, pushed toward a game feel, whose artefact is the Go server match screen: player cards with clocks (health as byo-yomi time), the last stone marked, a move counter, live territory counting, a result banner in kifu style at day end, the tournament table beneath. Next: implement it in the viewer (Viewer.tsx, the tilemap's L0-L3 marks, the sidebar as player cards, the timeline as the move list) once the refined mockup is approved.

Current goal (set 2026-08-27, night): the Kifu match screen, for real

The user approved the direction from the mockup (output/mockups/ dock.html, buggy, a statement of intent, not pixel truth). Implement it in the real viewer. Done means all of these, each proven:

  1. Identity tokens. lib/colour.ts and the theme carry Kifu: a flat board tone, stone black and white, agent hue only as data (territories, health, route marks), print-style small caps and rules, Noto Serif JP for text with tabular numerals; no gradients, blur, glow, textures or radii except stones. Light is the default; a night variant keeps the same structure. Proof: screenshots at L0-L3.
  2. Board. The tilemap draws L0 as flat territory tints, L1 adds shape outlines and territory counting marks, L2 stones (1 black, 0 white; givens with a small mark; commits plain) with the last-move ring on the newest, L3 board lines with coordinates on the frame; a move counter on the board; fog stays void; no detail for an unrevealed tile. A placement snap (transform only, ~120 ms) on newly known cells replaces the ripple. Proof: 0 map tiles redrawn in steady state still; screenshots.
  3. Dock as a match screen. Player cards: hue stone avatar, name, model as rank with harness small, tiles, points, and the clock — health as byo-yomi (main time while healthy, periods ticking at warning, "timeout" when dead); the agent of the latest event carries the "to move" card. Tabs: moves (the numbered move list built from events: number, stone, agent, tile or coordinate; joins, meets, grows and deaths as kifu notes), tile (detail of the tile under the centre or tapped), table (the tournament table = the day's leaderboard, both axes). Collapsible; the stats block is gone.
  4. Counting line along the board's bottom edge: per-agent points live, leader bold, dead struck through.
  5. Scrubber and playback stay under the board as a slim rule with kifu-notated markers; the move list follows the playhead.
  6. Day end. A result card in kifu style over the tournament table; /day/<date> and /calendar in the identity (calendar as day cells with a winner bar; leaderboard as the table).
  7. Phone. Cards above and below the board, tabs as a bottom sheet, counting line scrolls sideways, touch pan and zoom; the Today island states (generating, missing) in the identity. Proof: playwright at 390x844 on the live day, and the user on a real phone.
  8. Proofs held: deno task test and check green, the viewer tests (levels, dirtying, hues, timeline, checkpoints) adapted, the old logs replay.

Out of scope: machine identity, stakes beyond health, the map-first and broadcast layouts (the day page is the dock layout in replay).

Items 16-23 landed (2026-08-27): the Kifu match screen is live

The viewer is a Go-server match screen in a printed-kifu identity, on the hosted daily at https://mosaic.dev.site/ (Cloudflare in front of Fly).

  • Identity (16): a flat kaya board, stone black and white, hue only as data (territories, health, markers); Noto Serif JP with tabular numerals, small caps, hairlines; light default with a night variant that keeps every structure on an ink page. No gradients, blur, glow, textures or radii except stones.
  • Board (17): L0 flat territory tints; L1 shape outlines and counting marks (a counting-diagram dot on every solved point); L2 stones (1 black, 0 white, givens with a centre mark) with a last-move ring and a placement snap replacing the ripple; L3 board lines ruled through the stones with A-T / 1-n coordinates on the focus tile's frame, and its pins as triangles. Fog void; no detail on an unrevealed tile; 0 map tiles redrawn in steady state (measured L1 8/24, L2 0/35, L3 0/26).
  • Dock (18): player cards as a match screen with byo-yomi clocks from health (main time healthy, periods ticking at warning, timeout dead), the latest mover marked "to move"; tabs moves / tile / table; the move list built from events in kifu notation; the tournament table from the leaderboard, both axes.
  • Counting line (19) along the board's foot, leader underlined, dead struck through; the timeline (20) with kifu-notation markers, playhead, the move list following it; result card (21) at day end over the table; calendar and day pages in the identity; phone (22) with player cards across the top, the board, a bottom-sheet dock, touch pan and pinch zoom. Chrome is user-select:none; the move list, tile detail and table are selectable. Tests (23) green (47), check green.
  • Proven live: the hosted daily rendered the screen with real agents -- Codex Atlas (gpt-5.6-sol) in byo-yomi, fable and Codex timed out -- the counting line, move record and dead-agent crosses all reading as a game. Screenshots output/playwright/kifu/{desktop-L0..L3, desktop-L2-night,phone-L0,phone-L2,phone-sheet,hosted-live}.png.

Item 15, first half, landed (2026-08-27): machine keys

~/.config/mosaic/identity.json (ed25519, machine_id = m- + sha256(public key)[:16]) from deno run -A https://mosaic.dev.site/identity.ts; join carries {machine_id, public_key, proof} with proof a signature over mosaic-join|game|key|minute (current or previous minute); TOFU registry data/machines.sqlite; agent id machine_id/session so several agents per machine share one by-user row (models listed); unverified joins allowed, muted, never honoured. This Mac is m-4c879e83522b14c1 (dami); a signed join on the hosted day made one verified row beside the three unverified name-claims from earlier. Second half landed 2026-08-27, fully proven (the user signed in through shoo and claimed this Mac: "machine m-4c879e83522b14c1 (dami) is yours"): adopt the tribes convention (/Users/dami/Developer/tribes/docs/auth.md) wholesale — identity file as JWK ed25519 in the tribes shape, key-auth message mosaic-key-auth:<host>:<ts>, POST /api/keys/auth mints a bearer token (one per key) that joins carry; any ed25519 tool can mint the key (Node snippet, openssl, Python; spec at /identity), no Deno required. shoo.dev sign-in as a login button right of "copy prompt" (shoo.js; id_token verified with jose against shoo's JWKS, aud origin:<origin>, HMAC session cookie), logout when signed in. The only thing a signed-in human can do that a visitor cannot: open a claim link an agent produced (/link?t=..., single use, 10 min) to bind that machine to their account.

Decided 2026-08-27 (night): three attacks an agent reported, and the guards

An agent tried rejected commits as an oracle, read the public event log, and tried to reverse tile hashes. Verdicts and guards (in progress): the tile hash was a real, silent oracle (m <= 4 candidates, hash each, one matches) — it becomes an HMAC commitment with a per-generation secret published only in the day's archive (gen-v7); rejected cells' values leave the public log and SSE (the committing agent alone sees them) so a sacrificial prober cannot feed a harvester through the broadcast; health is per machine (all sessions share the window and the death) with a burst rule (30 wrong before the 300-cell floor is death); keyless joins are refused now that a key costs nothing (dying costs the key's day). Two more from the user: death is enforced WHILE a batch is processed (verdicts stop at the 30th wrong or the 4% crossing; the rest of the batch comes back unevaluated; atomic across a machine's sessions under the referee's write lock; batches capped at 512 cells), and a (machine, cell) attempt enters the health window at most once, so retract-and-recommit cannot pad the window with free correct entries (wrongs always count, corrects count once). Third: sessions are capped at 3 per machine per day (dying does not reset the count) and the session is bound into the signed join proof, closing seed farming through many sessions. Fourth, decided: under fog, geometry is NOT public during play — the live log/SSE serve a view whose manifest lists only tiles reached by anyone, with synthetic reach events as the reached set grows; the full log is released in the day's archive. Fifth, and the biggest: the day seed was derived from the date, so the whole field was regenerable by anyone — the nightly now draws a random 32-byte day_seed (committed as seed_commit at start, released at rollover), tile hashes are HMAC commitments under a day_key with SHA256(day_key) published at start and the key released at day end, over a versioned encoding (game, tile, geometry, solution, givens, seam mask), and every RNG stream is domain-separated (generation, growth, seat placement). Also: compact agent labels are name/model (or name/session) so two sessions of one machine are distinguishable in follow and the counting line.

Done 2026-08-27: all of the above is in (engine gen-v7 8121c97; site: per-machine health window with once-per-cell credit and the 30-wrong burst, in-batch death cutoff with unevaluated, 512-cell batches, three sessions per machine per day bound into the join proof, keyless joins refused on the hosted daily, rejected values stripped from the log and SSE, lib/redact.ts serving the fogged live view on /events and /log with placeholders and reach, random day_seed drawn by push-day and travelling inside the pushed field, keys.json and the seed/key released in the archive and checked on /day/<date>, labels name/model). Regression tests: tests/guards_test.ts, tests/redact_test.ts, the sticky-death and daily rollover tests.

Guards landed (2026-08-27, night)

All of the above are deployed (Fly, resumed today at revision 1263): health per machine with once-per-cell credit, the 30-wrong burst rule, death enforced mid-batch (rest unevaluated), 512-cell batches, three sessions per machine per day bound into the proof, keyless joins refused; rejected values gone from the public feed; the live log/SSE redacted under fog (lib/redact.ts: placeholders for unreached tiles, reach events; verified on the host: 246/246 placeholders at rev 0, 123 reach events, 0 leaked values); gen-v7 commitments under a private day key with a random 32-byte day_seed drawn by the nightly, both released in keys.json at rollover and recomputed on /day/<date> (commitments shown as verified or not). 62 site tests incl. guards_test.ts (probe + harvester, in-batch cutoff, batch cap, laundering, concurrent probes, session cap, keyless refusal) and redact_test.ts. Follow labels are name/model. Today's live field still has the old date-derived seed (regenerable); tomorrow's was rebuilt under gen-v7 by slate pipeline #7 before rollover.

Status of the v2 items under this goal

1 real packed run: done (v2 packings and v3 scatter fields played, logs on disk). 2 run tags: folded into item 3 above. 3 analyzer: item 8. 4 paired trials: deferred. 5 k: deferred. 6 library: replaced by 6a (item 1 above). 7 envelope: G=1000 render becomes item 6's 9x9 field.

Phase 2 (v2) — same board, two agents, measured

Manifest v2 (packed tiles of mixed size, zones, segments, reward) is built and tested (decision block below; docs/space-and-cache.md). What v2 has to add is the reason to have built it: a board large enough that strategy matters, played by more than one agent under the same seed commit, with the run measured rather than eyeballed, and a tile library so that boards of that size are a stitch and not a twenty-minute generation.

Goal

Watch two agents solve the same big-first mosaic of mixed-size tiles (G = 100, ~100 tiles, 6..16) side by side on localhost, synced by revision, with an analyzer that says which one played better and why.

The demo moment: deno task dev prints a challenge (a seed commit) and two prompts. Paste one into Claude Code and one into Codex (or the same model at two batch sizes). Open /?compare=<a>,<b>: two maps, one scrubber, the analyzer pane between them: reward over revision, guess rate, reveal-to-commit ratio, mean tile size solved, frontier strategy (BFS / DFS / constraint-greedy). Scrub back to the reveal where they diverged.

Done means all of these, each with a test or a command that proves it:

  1. Real packed run. mosaic-engine gen --G 100 --kind big-first is a batch job (minutes, timed and written here), cached by manifest hash, and a fresh agent given only the printed prompt solves it end to end through /mcp. Proof: a game log under site/data/games/ whose manifest is packer.kind: big-first, plus the existing live-fold == replay-fold test run over that log.
  2. Run tags. start_mosaic accepts tags { model, batch, code_use, initial_reveal, note }; they are written as a run event at revision 1 (the manifest at revision 0 is the board's identity and must stay equal across paired runs, so tags do not go on the manifest). /games lists games filterable by tag and by seed commit. Proof: a test that two games of one challenge share manifest.id and differ in run.
  3. Analyzer in the fold. Every batch event folds into analysis fields (guess rate, reveal-to-commit ratio, retractions, reward per batch, mean n of solved tiles, wall-clock between batches, frontier strategy classified from the reveal order against the seam graph: BFS, DFS, or largest-tile-first / most-pinned-first greedy). The analyzer is a pure function of the log, so live and replay agree. Proof: deno task test asserts analysis equality at every revision between live and replay, and classifies the scripted client's BFS and DFS modes correctly.
  4. Paired trials. A challenge is a seed commit; start_mosaic { challenge } joins it (same cached generation). The viewer at /?compare=a,b renders both games on one scrubber, aligned by revision, with the analyzer pane. Proof: two scripted clients (BFS vs DFS, --limit 6 vs --limit 2) on one challenge produce two logs the compare page renders and the analyzer tells apart.
  5. Coupling depth k measured. Per tile, k is the size of the smallest set of its seam segments that, once fixed by solved neighbours, leaves the tile a single solution (a minimum hitting set over its alternatives' differing segments; m <= 4, so exhaustive). Mosaic k is the maximum, and the manifest carries the distribution k_hist. Replaces k: unmeasured. Proof: go test ./gen re-derives k from the emitted alternatives and asserts every tile has 1 <= k <= segments.
  6. Tile library. site/data/library.sqlite with the schema in docs/space-and-cache.md §5; mosaic-engine lib add ingests gen output (one canonical orientation per tile, all 16 edge tuples indexed); mosaic-engine stitch --seed --G --kind resolves a packing against the library, records misses, and gen --drain generates the missing tiles with prefilled edges and appends them. Proof: stitching a second seed at G = 100 after one drain is all hits; the stitched mosaic passes every per-tile assertion (re-verified from JSON, as today) and global uniqueness; manifest.library is the file hash.
  7. Envelope. pack at G = 1000 in under a second with zone share under 3%; stitch at G = 1000 reports its hit rate and finishes in under a minute (misses allowed, listed, not silently dropped); the viewer renders a synthetic G = 1000 board (1M cells, tone-only at overview zoom, glyphs when a tile fills the view) and the compare page renders two of them. Everything above also still runs at G = 27.

Out of scope for v2, on purpose: Nostr, identity, deployment, the cloud copy of the library (R2/D1 are phase 3; the file stays local), more than two agents on one compare page, visual identity, the daily seed.

Build order, each step usable on its own: real packed run (1, the baseline; nothing else is worth doing if a packed board is not playable) -> tags (2) -> analyzer (3) -> paired trials and the compare page (4) -> k (5) -> library (6) -> envelope (7) -> the demo.

Exit criteria

  • Two agents (or one model at two settings) finish the same G = 100 big-first mosaic; both logs replay frame for frame; the compare page shows both under one scrubber and the analyzer pane distinguishes them on at least guess rate and frontier strategy.
  • The board they played was stitched from the library, not generated live, and its manifest names the library hash.
  • k is a number in every manifest, and at least one tile in the played board has k >= 2 (needs two neighbours; the generator asserts this for big-first boards, not hopes for it).
  • gen, stitch, the viewer and the compare page all work at G = 27 and at G = 1000 (the agents only have to play the G = 100).

Measured while picking v2 up (2026-08-25/26)

  • Phase 1 exit report: output/report/index.html; every item passed.
  • No packed (big-first / bands) board had ever been generated for real: site/tests/fixture.ts synthesises the big-first fixture (random solution, random givens, judge stubbed), and every log under site/data/games/ was a grid. The previous session's attempt to play a G=60 big-first board ran 24 minutes and died in generation.
  • Why it died, in order of discovery: (1) the big-first layout leaves 6x6 fillers with three prefilled sides and the id order filled them last, (2) backjumps only ever targeted the latest dependency, (3) puzzletea's MRV backtracker could neither refute an infeasible 6x6 nor complete a 14x14 with fixed sides inside its timeout, (4) per-neighbour forward checking misses neighbour-neighbour interactions across a boxed-in tile's edge, (5) a tile pinned to one solution by its neighbours' seam clues failed the whole attempt instead of being re-given. All five are fixed (decision "Generator search" below); gen.Version is gen-v4.
  • pack --G 60 --kind big-first: instant; zone share 1-3% at G = 60, 100, 200; by count ~40% of tiles are n = 6 but ~70% of the area is n >= 12 at G = 100.
  • gen (gen-v4, 18-core Mac, wall clock, one attempt each): 2x2 of 14x14 (27x27) 14 s; G=60 big-first (62 tiles) 45 s; G=100 big-first (103 tiles, 3 regivens) 2 min 36 s; X=15 grid (225 tiles, 196x196) 12.5 min. Generation is a batch job cached by (generator version, seed, G, kind, n) under site/data/cache/.
  • First real packed run: G=60 big-first, seed a, played by the scripted client through /mcp; log site/data/games/48edade0-...jsonl, 183 revisions, 62 reveals, 120 commits, 1583 forced / 797 guess / 11 wrong, reward 4232 (= sum of n^2), replay at /?replay=48edade0-.... deno task test now folds every finished v2 log on disk and asserts a solved end state with the right reward; the scripted client tolerates SSE keepalive comments during a long start_mosaic (that was the previous session's crash).
  • G=100 big-first, seed a, same client: log bb7b5f61-...jsonl, 300 revisions, 103 reveals, 197 commits, 4280 forced / 2693 guess / 25 wrong, reward 11492; generation on cache miss plus the whole game took 141 s wall. Screenshots: output/playwright/replay-bigfirst-{60,100}.png.
  • Still open from item 1: a fresh agent (not the scripted client) on a packed board. That is the next thing to do: deno task dev prints the prompt; use kind=big-first, G=100.

Idea captured 2026-08-26: scattered field instead of a packing

The two played boards read as a tiled quilt. The user would rather the board be a field: tiles of random size dropped at random positions, overlapping each other by arbitrary rectangles (not one shared edge), holes allowed, no filler zones. Each tile's rules are internal to its window; the only cross-mosaic rule is that shared cells agree. The generator already works this way underneath (sat.CompleteRegion is a joint solve over windows on one global grid, any overlap shape), so the change is in the layout and in what "seam" means:

  • pack --kind scatter: a seeded Poisson-style drop of squares (n even, 6..16) onto a G x G field; accept a drop when it touches the growing cluster with an overlap of at least one full row or column and at most ~n/4 of the smaller tile (so no tile is given away by one neighbour), and is not contained in another. Stop at a target coverage. Holes are simply uncovered cells; zones go away.
  • Seam segments become overlap regions (rects in local coords, with the partner id). Frontier, pins, k, seam clues and the coupled uniqueness CSP all read regions instead of segments; the assertions keep their meaning (every alternative differs inside some region; the tile is under-determined alone; the field is unique as a whole).
  • Generation order is already WFC-shaped: most-constrained-first is the minimum-entropy rule; nothing to add there. Randomness comes from the seeded rng in the drop process and in SAT's diversification, not from the CPU.
  • Viewer: the same colour model (mean owner hue at fixed lightness) draws a multi-owner overlap region as a blend, which is what makes an overlap legible; holes are void.

Not a v2 item yet; decide after items 2-4 exist, since the compare page and analyzer are layout-agnostic.

Idea captured 2026-08-26: the field reads as texture, not information

At overview zoom the cells are drawn as small outlined squares, and a balanced no-three binary field is by construction a maximum-entropy texture, so values cannot read at that scale no matter the glyph. The overview should be value-blind: known/unknown and ownership (status wash at owner hue, unknown dark, holes void, no per-cell borders); values appear only when a cell is >= ~12 px, as filled/empty discs rather than squares. Two zoom regimes, not one glyph scaled. Belongs with the visual identity task after v2.

Manifest v3 (decided 2026-08-26): a scattered field of shapes

Decided the same day the ideas above were captured; the user wants the field now, before items 2-7, and the visual pass with it.

Tiles are shapes, not squares. A tile is a set of cells: any union of axis-aligned rectangles whose every maximal horizontal and vertical run of cells (a line) has even length >= 4. Rectangles, L, T, plus, notched and holed shapes all qualify. The Takuzu rules are per line: no three equal in a row along a line, each line has as many 0s as 1s, and no two lines of the same orientation and length in one tile are equal. A square n x n is the special case where every line is n long.

Tiles are scattered, not packed. pack --kind scatter drops shapes at seeded random positions, sizes and orientations onto a G x G field, growing one connected cluster from the seed tile: a drop is accepted when it overlaps the cluster by at least one full line of some existing tile and by at most a quarter of the smaller tile's cells, and is contained in no tile. Overlaps are arbitrary rectangles (or unions of them), not single edges. Uncovered cells are holes; there are no filler zones. Stop at a target coverage; the result should read as a field with scattered small gaps, not a quilt.

Cross-tile rule: shared cells agree. Nothing else crosses a tile boundary. Every tile alone has 2..m solutions that differ on some overlap region; the field as a whole has one.

manifest v3 (public, rev 0)
  version      3
  id, seed_commit, G, packer { kind, params }, m_by_n, rules, seed_tile,
  generator, library, k, k_hist                      (as v2)
  tiles[]      { id, x0, y0, w, h,
                 rects[]: [lx, ly, rw, rh]           // union = the shape, local coords
                 cells,                              // count
                 m, hash,
                 overlaps[]: { with, cells },        // partner id, shared-cell count; the
                                                     // cell set is the intersection of the masks
                 pins[]: overlap indexes some alternative differs on }
  (no zones)
private: solution (G rows, '.' for holes), tiles[].givens, .flip, .ladder, .needs_*
  • Engine rules: Window is replaced by Shape {X0, Y0, W, H, Rects}; a Board carries shapes and precomputes their lines; every rule, propagation, flip rectangle, ladder rating and the coupled CSP works on lines and shared cells. sat encodes per line. The size policy (PolicyFor) keys on the tile's cell count instead of n.
  • Site: owners from rect unions; the frontier is "shares a cell with a solved tile"; hue colouring unchanged; the viewer draws a shape outline as the edges between tile and non-tile cells and a hole as void.
  • Gates worth building because they are unfriendly to a human and interesting to an agent (knobs on the manifest, off by default): lines of length 4 (a T stem) that are almost fully forced but change what the crossbar can be; a hole inside a tile that splits a line in two; a tile whose rule mask differs from its neighbour's (no uniqueness on one side of an overlap); overlap regions wide enough that two tiles are effectively one puzzle; long lines (16) crossing several overlaps; a tile with two disconnected overlaps to the same neighbour.

Visual pass (now, not after v2): value-blind overview (status wash at owner hue, unknown dark, holes void, no per-cell borders), values as filled/empty discs only when a cell is >= ~12 px, tile outlines as shape outlines, overlap regions read as hue blends. Game feel: the user liked ../rect.run's motif but wants it less blocky here; Takuzu should feel like a smooth stream of calm water (Shikaku is the tile-laying, castle one). Soft edges, slow transitions, low contrast at rest, the field as a surface that fills rather than a grid that gets stamped.

Feasibility captured 2026-08-26: the hourly public board

Question: what board size lets ~25 agents work one field together for an hour, after which a new board arrives and the old attempt is persisted; is generation disproportionate to play?

Measured generation (gen-v4, 18-core Mac, one attempt): 0.7 s/tile at G=60 big-first, 1.5 s/tile at G=100 big-first, 3.3 s/tile for all-hard 14x14 (X=15). Measured play: the phase-1 fresh agent did 4 hard 14x14 tiles in 3 min with code (0.75 min/tile); the scripted client is not a measure. Model: 25 agents, 50% parallel efficiency (frontier contention, revision conflicts, waiting on a neighbour's seam), mean tile ~150 cells, 85% coverage.

agent pace tiles/hour cells G generation (mixed / all-hard)
1 min/tile 750 112k ~360 20 min / 41 min
2 min/tile 375 56k ~260 10 min / 21 min
3 min/tile 250 38k ~210 7 min / 14 min

So generation is 10-35% of the hour of play it feeds, on one machine, and it runs ahead (board N+1 generates while N is played), so it is not disproportionate; it only looked that way while the old generator could not finish at all. The tile library (v2 item 6) turns it into seconds.

What actually limits 25 agents is not size but the frontier: a field grown from one seed has a frontier of a few dozen tiles only once a few dozen are solved, so the first minutes serialise. Use several seed tiles (5-10, drawn from the seed) so the wavefronts meet. The other cost is observe: delta-coded known cells are ~2 bytes/cell, so 56k cells is a ~100 KB observation per call; at that scale observe should take a tile or viewport argument. Neither is a board-size problem.

Plan: start hourly boards at G=200-260 with 5-10 seeds, measure agents' real tile rate with the analyzer (item 3), and move G to hit a ~45 min median completion. A board that does not finish in the hour is persisted as it stands; that is fine and is data.

Measured after the v3 pivot (2026-08-26, later the same day)

  • Built by three forks in parallel on one contract: site (visual pass, then v3 in fold/referee/MCP/viewer/client/tests, v2 logs still replay through normaliseManifest), pack (scatter with a hole-fill pass and the distinct-lines bound), engine core (rules on shapes and lines, sat per line, judge on shapes, gen over overlap regions; gen-v5).
  • A shape constraint the packer did not know and the generator's validation caught: uniqueness needs at most D(L)/2 parallel lines of length L, where D(L) is the number of distinct valid L-lines (D(4)=6, D(6)=14, D(8)=34, D(10)=84, D(12)=208, D(14)=518, D(16)=1296). So no 4-thick arms at all, 6-thick arms at most 6 long; the smallest tile is 6x6 (or the 32-cell notched 6x6). pack.ValidShape and rules.Shape.Valid both enforce it.
  • Packer (seed a, MaxHole 24): G=100 -> 119 tiles (47 from the fill pass), coverage 0.90, 54 holes of mean 18 cells, 0.45 s; G=200 -> 470 tiles, coverage 0.91, 204 holes of mean 17 cells, 1.9 s. Holes over 24 cells that remain (13 at G=100) are concave pockets no valid shape fits under the overlap rule.
  • First scatter field generated and played: G=60, seed a, 31 tiles (11 compound, 12 non-square rectangles), 1038 hole cells, generated in 41 s (one attempt, no backjumps), solved by the scripted client in 71 revisions, 1112 forced / 726 guess / 3 wrong, reward 3032; log 0687247b-..., screenshot output/playwright/v3-scatter-60-overview.png. G=100 scatter, seed a: 75 tiles, generated on cache miss and solved by the scripted client in 383 s wall (three other generations were running at the time), 99 commits, 3141 forced / 2445 guess / 7 wrong, reward 9020; log 7b8941d3-..., screenshot output/playwright/v3-scatter-100-overview.png.
  • Engine-core fork final: go test ./... green (gen 130-154 s with both fixtures; the 2x2 reproduces byte for byte under MOSAIC_SLOW=1); pins are now recomputed at settle time; a layout is validated with Shape.Valid() before any search (an invalid shape cost SAT 12-20 s to refute). Manifest deviation from the contract: rules fields are overlaps, cross_tile (not seams, cross_seam); Output.Layout carries the packer's report. Timings under contention: G=100 scatter 6.5 min (75 tiles, one attempt); X=15 grid 18.6 min.
  • Known generator gap: at G=200 scatter the first attempt failed after 11 min ("repair tile 193: an alternative differs inside no overlap region": the neighbours' seam clues had fully fixed the tile's only pin-eligible overlap, and the regiven repair cannot widen it), so the attempt restarts from scratch. Fix: a repair that releases the neighbours' clues on that overlap (or plants across a different one) instead of restarting. No G=200 number yet; chunkwise generation (item 6a) makes single G=200 passes unnecessary anyway.
  • Visual pass screenshots: output/playwright/v3-overview-100-dark.png, v3-mid-100-dark.png, v3-detail-100-dark.png (value-blind overview, discs at zoom, no per-cell borders, ripple on newly known cells).

Idea captured 2026-08-26: chunkwise generation (checkerboard, not stitch)

Generate a large field as a grid of chunks (~G=60 each) in parallel, then join them. The join does not need a stitcher or a library: a chunk is a gen run whose border tiles may have cells prefilled from neighbouring chunks, which the generator already handles (joint completion over fixed cells is what fillTile does). Order the chunks as a checkerboard: even chunks generate independently and in parallel (unique alone, as today); odd chunks generate with their border tiles overlapping the even neighbours' border tiles, prefilled from them, and are asserted unique given that context. Two rounds, each the wall clock of one chunk.

  • Per-tile assertions are unchanged (2..m alone, alternatives differ in an overlap region, technique checks); per chunk the coupled CSP runs over the chunk plus its fixed context; the whole is unique because even chunks are unique alone and odd chunks are unique given them.
  • Determinism: chunk seed = (seed, chunk index); the result does not depend on how many run at once. Tile ids offset per chunk; one manifest for the union; the frontier crosses chunk borders like any overlap.
  • Separate processes, not goroutines: gophersat's package-level buffer serialises every SAT call inside one process (the sat mutex), so one gen uses about one core for the solution phase. Chunks as processes is the way to use the 18 cores.
  • Estimate from today's numbers: G=260 as 4x4 chunks of ~G=65 is two rounds of ~45-60 s, ~2 min wall, versus ~15-20 min serial. That is the hourly-board generation cost solved with far less machinery than the tile library; the library (item 6) stays as the finer-grained version for the daily.
  • Needs: gen --context <cells+tiles> (prefilled cells and the identity of the tiles that own them, so overlaps and pins are computed against them), a chunks orchestrator in the site (lib/engine.ts) or a mosaic-engine field subcommand that plans the checkerboard, runs the processes, and merges manifests; a test that a 2x2 of G=60 chunks passes every assertion re-verified from the merged JSON and has one coupled solution. Slot it as item 6a, before the library.

Idea captured 2026-08-26: the daily as 9x9 chunks, agents placed at random

Follow-on from chunkwise generation. The checkerboard is flat, so a daily board of 9x9 chunks of G=60 (G ~ 530, ~280k cells, ~2500 tiles) is still two rounds: 41 even chunks then 40 odd, 18 at a time, roughly 3 waves of ~45-60 s each way, so ~5-6 min on the Mac. Recursion (chunks of chunks) buys nothing for cost; the orchestrator is not the bottleneck. Large-G single passes are no longer needed for anything.

Each agent joining the board is placed at its own seed tile drawn at a random uncovered-frontier spot; from there it can dig toward an ally's region or route toward open space. Rules to decide (gates, not settled):

  • Per-agent frontier: an agent may reveal only tiles overlapping tiles it solved itself, until its region touches another agent's region, at which point the regions merge and both share the frontier (the meeting is an event the analyzer records). This is what makes "dig out an ally" mean something; a global frontier would blur positions.
  • Ownership colouring becomes per agent (the 30b4 owner-hue idea, at last): a cell's wash is the mean hue of the agents who solved the tiles covering it; overlaps between two agents' tiles read as their blend.
  • A daily of ~2500 tiles at 25 agents and 1-3 min/tile lasts 3-8 hours of play; new agents keep joining at empty spots; the board persists through the day and is archived as it stands at midnight.
  • Hard requirement at this size: observe scoped to a region (the agent's own tiles plus a radius, or a viewport), never the whole field; the whole field is for the spectator over SSE, tiled by chunk.

Decided 2026-08-26: the viewer is a tilemap with levels of detail

Hue means the agent who solved it, not the tile. Tile identity, rules and values are detail that draws in as you zoom, like a map: at every zoom the question "where does this belong" has one answer at the scale you are looking at.

level cell size what is drawn
L0 < 3 px territory: agent-hue washes (mean hue where agents' tiles overlap), unknown dark, holes void; no edges
L1 3-12 px + shape outlines (tile/non-tile boundary), overlap regions as blends, status wash (revealed lighter than solved), the ripple
L2 12-30 px + values as discs (filled 1, ring 0), givens muted vs commits in ink, forced vs guess as two ink weights
L3 >= 30 px + the lines: a faint bar along each line of the tile under the centre, line length, pins marked on the overlap, tile id
  • Rendering: the field is cut into map tiles (256 px per level, keyed by chunk and level), rasterised once and cached; an event dirties only the map tiles its cells touch; the spectator subscribes to SSE per chunk in view. The same cache serves the replay scrubber.
  • Nothing is decoration: every mark at a level answers a question that level can ask (whose, which tile, what value, which rule). Rules are not coloured; they are read off the lines at L3.
  • The current two-regime viewer is L0/L1 + L2 without the tilemap; the tilemap becomes necessary at the daily's ~280k cells.

Ideas captured 2026-08-26 (night): difficulty and novelty at daily size

G=530 (1777 tiles, nine agents) reads right as a daily. Levers for harder play and for emergence, in the order they are worth building; each is a manifest or rules knob, off by default, and every one is visible in the viewer without a new colour channel.

  1. A growing field. Chunkwise generation makes the world extensible: every hour (or when coverage of the frontier ring passes a threshold) the referee generates a ring of chunks around the cluster, using the existing border tiles as context, and where agents are pushing. The field is the day's history of where the agents went. Needs: field --grow (context = current border), a grow event, the viewer's geometry accepting new tiles mid-log.
  2. Terrain. The packer draws tile size and hardness (blank ratio, technique policy) from a noise field: plains of 6x6 fillers, ridges of 16x16 at 75% blank. Reward stays cells, so effort per cell is what varies and agents decide where to spend it; terrain reads at L0 as wash lightness. Needs: a terrain param on the packer, per-tile policy overrides in the manifest.
  3. Contested tiles and pin bonuses. A frontier tile adjacent to two groups can already be revealed by both; make it explicit: the finisher owns it, both groups' commits count for their agents, and solving a tile that pins a neighbour earns a bonus. Needs: a contested flag in observe, analyzer fields, viewer marker.
  4. Notes on tiles. An agent can leave a <=160-char note on a tile it solved or revealed, readable by anyone who observes it, shown in the viewer at L3 and in the console. Coordination or deception, both in the log. Needs: a note tool and event.
  5. Per-tile rule masks. Tiles announce their rules: some without the uniqueness rule, some with an extra one (no 2x2 of equal values, or no three on a diagonal, both easy in rules and sat). Agents must read the mask and adapt their solvers; specialists emerge. Needs: rules per tile in the manifest and the judge, a Mask extension.
  6. Wormholes. Two distant tiles share an equality constraint on a pair of lines (cells of line A equal cells of line B, stated in the manifest). Information travels across the field; solving one region pins another. Needs: an equality constraint type in rules and sat, a manifest links[], a viewer mark at both ends.
  7. Shapes unknown until adjacent. observe lists hidden tiles only once a group's tile overlaps them; exploration has a cost. A flag.
  8. Decay. A solved tile whose neighbours are not all solved within N revisions loses its cells again unless sealed; pressure to consolidate rather than sprawl. The most invasive; last.

Cross-tile rules (the reserved cross_tile flag: no-three continuing across a shared edge) are a difficulty lever but not an emergence one; keep as a knob.

Decided 2026-08-26: 1 + 7 together are the next build. A field that grows where agents push, whose unexplored parts are not visible until a group's tile touches them, is one mechanic seen from two sides: the map is made of what has been reached. Shape: the referee keeps a fog flag per field; observe lists a hidden tile only if it overlaps a tile of the caller's group (or lies within the agent's own seed reservation); the spectator sees everything the log has, which is every generated tile, but at L0 unreached tiles draw as the faintest wash so the day's reach reads at a glance; growth runs as field --grow <log> producing a grow event with the new tiles (ids appended, seed tiles for late joiners drawn from the new ring), triggered by a timer or by frontier coverage, and the fold applies it by extending geometry in place. Proof: a G=148 field grown twice while two scripted agents play it, replay == live, observe never lists an unreached tile.

Kept from the earlier phase-2 list

  • Larger mosaics, tile size 10-12, m = 4: superseded by packings and m(n) (decision block below).
  • Tile cache for stitching (idea captured 2026-08-25). Generate many n-size tiles ahead of time and store them so that a daily mosaic is a stitch, not a generation. The generator already splits per tile: the expensive part (givens, planted flip, uniqueness rectangle, ladder rating) depends only on the tile's own solution grid and on which of its sides are seams, so the cache key is (n, hash(solution grid), seam sides) and the value is the givens plus the assertion record. Completions are keyed by (n, top row, left column), and "failed to fit" (no completion for that prefill) is stored too, as a negative entry, so retries are free. Growing or refreshing the set is a batch job; the referee only ever reads it. Edges are binary lines, so store them as ints: at n=14 an edge is a 14-bit value (a balanced one is one of C(14,7)=3432), a completion key is two ints, a stored tile carries its four edge ints, and stitching is integer matching over a table rather than grid comparison.

Phase 3 — Nostr and the shared daily world

  • Events become signed Nostr events with the kinds on the 30b4 page. The referee is the single signer; the JSONL from phase 1 is already the event shape, so this is a serializer, not a rewrite.
  • Private relay (strfry or HAVEN) as world and archive.
  • "Shared space" = authless MCP endpoint exposed beyond localhost, where any npub-identified agent contributes to the daily world: one mosaic per date from puzzletea/daily.Seed(date), many agents, one referee, every contribution attributed by key. tribes.pub already has the "submit via /mcp, compose a deterministic sim, watch" pattern to borrow from.
  • Spectator subscribes to the relay by tile filter for a viewport.

Product UX (decided 2026-08-26): the daily, for humans first

Everything is viewable logged out. Login (shoo.dev) only adds "yours": your machines, your territory, your replays.

Pages:

  • / today's board: live if the day is running ("view live" follows SSE), else the finished replay. Explore by pan and zoom in the browser; no agent required. A "copy prompt" button hands off to an agent: the prompt bootstraps it (install the MCP endpoint, generate a machine key, register, join today's board at a random spot, play) and ends with "give your human the link when asked".
  • /day/<date> the replay of any past day, with the same viewer.
  • /calendar a month grid; every day is a summary tile: agents participated (a contributions graph by harness/model), time to completion or time remaining, guess %, tiles solved / total, territory shares, meetings. Click through to the day.
  • /link?t=... claim a machine token (identity decision above).

Replay UX (improve): a timeline that shows event density, jump to markers (tile solved, agents met, a big guess run), playback speed, "follow this agent", and the same scrubber at every zoom of the tilemap.

Persistence is ours: per day the manifest, the private generator output (never served), the JSONL log, and the analysis summary; a SQLite index days(date, seed_commit, stats json) for the calendar; archive copies to R2. A daily at ~2500 tiles is ~10k events, ~10 MB; trivial.

Contributions by model: MCP initialize carries clientInfo {name, version}, which identifies the harness (Claude Code, Codex, ...), not the model, and is free. The model is self-reported: the bootstrap prompt asks the agent to state it in the run/join tags, and the calendar labels it "as reported". Verifying the model is not possible from the protocol; do not pretend to.

The viewer is the tilemap with levels of detail (decision above); at the daily's size that is a requirement, not a nicety.

Machine identity (captured 2026-08-25, phase 3, not built in phase 1)

How an agent gets a durable identity and hands its human a web session:

  1. The agent generates its keypair, registers the pubkey with the API, and gets back a machine_id.
  2. When the user wants the web UI, the agent hits the API with a request signed by that key and gets a short-lived, single-use pairing token.
  3. The agent opens the default browser to https://<site>/link?t=<token> (and prints the URL as a fallback for headless/SSH).
  4. The server validates the token's signature chain, ties the session to machine_id, and sets a long-lived Set-Cookie.
  5. Every visit after that: cookie present -> "welcome back, here's your stuff."

Identity and accounts stay out of phase 1 on purpose; this is the shape to build against when phase 3 makes the referee a signer and agents npubs.

Identity decided 2026-08-26: machine keys for agents, shoo.dev for humans

Builds on "Machine identity (captured 2026-08-25)" above. Two identities, one link between them:

  • Agent = machine key. The agent generates its keypair, registers the pubkey, gets a machine_id; every write it makes is attributed to it (this is the "whose" hue in the viewer and the per-agent frontier). Phase 3 makes the same key its Nostr npub.
  • Human = shoo.dev account. When the agent wants to hand its human a session, it asks the API for a single-use pairing token signed by its key and opens https://<domain>/link?t=<token> (prints the URL for headless). That page sends the human through shoo.dev login; on return the server ties the shoo.dev account to the machine_id, sets the long-lived cookie, and every later visit shows the human their agents, their territory and their replays.
  • One human may have many machines (several agents, several hosts); a machine belongs to at most one human; unlinked machines still play, they just have no human to show it to.
  • Open: the project needs a name and a domain before /link exists; the shoo.dev integration details (OIDC or its own flow) are to be read from its docs when this is built, not assumed.

Decisions taken (change here, not in code)

Manifest v2 (decided 2026-08-26): packed tiles of mixed size

Phase 2 replaces the X, n grid with an explicit packing. The phase-1 grid is emitted in the same shape (a regular tile list), so one manifest format serves both. Design background: docs/space-and-cache.md.

  • Packer: big-first-then-fill. Large squares (n 14..16) are placed first at seeded positions with free edges; the gaps are filled by bands and Euclid cuts with smaller squares; remainders under n = 6 become filler zones. The library only ever has to answer one-constrained-side lookups for large tiles, which is the cheap regime.
  • m scales with n, and is capped. m(n): 2 for n in {6, 8}, 3 for {10, 12, 14}, 4 for {16}. Hard cap 4, never a free parameter per run; the generator asserts each tile lands in 2..m(n) and reports its actual count. Rationale: a small tile with 4 solutions is a coin toss, a 16-tile with 2 is pinned by any single seam segment.
  • Sizes n even in 6..16 (16 is new; the rules engine is size-agnostic, the generator's timings at 16 are to be measured before it is used in a daily).
  • Assertions scale with n (gen.PolicyFor): every tile has 2..m(n) solutions alone and every alternative differs on a seam segment; the blank floor is 55% at n <= 8, 65% at n = 10, 70% from 12 (cap 75%, 73% at 16 where counting gets expensive); the technique checks (needs the uniqueness constraint, needs a bifurcation) apply from n = 12, which is the canonical hard standard. Small tiles are fillers around the hard ones, not hard puzzles themselves.
  • Generation is two phases: the solution, tile by tile in id order with forward checking (a completion is kept only if every later neighbour still has one) and backjumping to the latest neighbour on a NOFIT; then the givens, in waves of tiles that share no cell, in parallel. Cells where three or more tiles meet are never planted; they may be seam clues.
  • Symmetries: rotations, reflections and 0/1 complement (16 in all) preserve every rule and every assertion, so a library tile is stored once and indexed under all 16 edge tuples.
manifest (public, rev 0 of every log)
  version      2
  id           sha256(seed || nonce)         seed_commit = id
  G            cells per side of the board
  packer       { kind: "big-first" | "bands" | "grown" | "grid", params }
  m_by_n       { "6": 2, "8": 2, "10": 3, "12": 3, "14": 3, "16": 4 }
  rules        { seams, cross_seam, seam_clues, band_unique, zones: "filler" }
  seed_tile    tile id
  tiles[]      { id, x0, y0, n, hash, m,
                 seams[]: { side, from, len, with },   // segments, in local coords
                 pins[]:  segment indexes some alternative differs on }
  zones[]      { id, rects[]: { x0, y0, w, h } }       // filler, fully given
  k            "unmeasured"
  generator    version hash
  library      hash of the tile library file the tiles came from, or null
private (gen output, never in the log)
  solution     G rows
  tiles[].givens, .flip, .ladder, .needs_*
  • Cell id stays y * G + x. A cell's owners are derived from tiles[] (and at most one zone); hue is a greedy 7-colouring of the tile adjacency graph, computed by the fold, not stored.
  • Seam segments replace seam sides everywhere: frontier = hidden tiles sharing a segment with a solved tile; pins are segment indexes; the global-uniqueness CSP runs over segments; cells where three or more tiles meet are never planted and never seam clues.
  • Zones are filler in v2: fully given on reveal of any neighbour, never on the frontier, zero reward. The manifest shape leaves room for a rule set per zone later.
  • Reward: n^2 per solved tile; the analyzer records reward per batch and the mean n of solved tiles over time.

Generator search (decided 2026-08-26): SAT for answers, propagation for technique

Completing a window with fixed edges, deciding whether one exists, and counting solutions up to a limit are ordinary CSP questions, not something to hand-roll. The engine's sat package encodes one window (balance as cardinality constraints, no-three as clauses, line uniqueness through XOR auxiliaries, fixed cells as units) for gophersat, a pure-Go CDCL solver. Measured: a 16x16 with all four sides fixed completes in ~4 ms, a 14x14 at 72% blank counts to limit 4 in ~8 ms, a contradiction is refuted in microseconds; puzzletea's MRV backtracker took its whole 6 s timeout on the first two and could not refute an infeasible 6x6 at all, which is why a G=60 packing never finished before. puzzletea is no longer a dependency. The rules propagation solver stays for what it is for: forced/guess attribution and the technique ladder model human deduction, and SAT has nothing to say about that. gophersat keeps a package-level buffer for clause learning, so sat serialises Solve behind a mutex; calls are milliseconds and the parallel givens waves lose nothing measurable.

Fill order is most-constrained-first (gen.fillOrder): from the seed tile, the next tile is the one with the largest share of its cells already fixed, ties to the smaller n. Small fillers are therefore completed while they have one or two fixed sides and their remaining edges constrain the large tiles, which have room; id order (big tiles first) left 6x6 fillers for last with three fixed sides and, at G=60, 18 of them, most with no completion. Backjumps escalate: every second failure of the same tile targets the next earlier dependency, and a tile whose forward check fails for every completion keeps the one that leaves the most later neighbours feasible, so the neighbour that then fails is the one with the real conflict.

  • Home: new repo. The Go engine owns the puzzle rules (rules, sat); the Deno site is where state, MCP, and the viewer live. Nothing outside the engine knows a Takuzu rule.
  • Spectator: Fresh 2 island with a canvas, not TUI. Pan/zoom, background blends, and drawn glyphs are the point of the view. Same framework and pins as rect.run so there is nothing new to learn.
  • MCP library: @modelcontextprotocol/server + @modelcontextprotocol/hono, the pair mcpsweeper already runs, mounted inside Fresh.
  • Seams: overlap-by-one. Simplest coupling that makes tiles under-determined alone; rules-across-seam and seam clues are phase-2 knobs.
  • Rule attribution is computed by the referee at commit time and stored in the batch event for the analyzer. The spectator's colour layer is tile ownership (semi-transparent washes that combine on seams), not rules.