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 > 1solutions); 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 andnis any even tile size in 6..12; every placement is computed from(tx, ty), pitchn-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 oneXis a bug.docs/takesmock-mosaic.py Xas 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 isforcedif that reaches it,guessotherwise. This is the same method the difficulty-rating literature uses, and it is polynomial ford = 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;
genis 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:/, 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:
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.mosaic-engine judgeclassifies a batch of commits as forced or guess by propagation (constraints 1-3, bifurcation depthdconfigurable) in under 50 ms per batch at n=14./servesmcp start_mosaic,observe,reveal,commit,retractwithexpected_revision, and a fresh agent given only the printed prompt finishes a game with no human help.- 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.
- 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.
- The envelope holds:
genruns 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 -> / -> viewer -> replay test ->
envelope test -> the demo.
Shape
Two processes, split at the puzzle boundary:
- Engine (
engine/, Go, importsgithub.com/via a localFelineStateMachine/ puzzletea replace). Pure functions over JSON, no state, no ports:mosaic-engine gen(seed -> tiles, givens, seams, m per tile, manifest) andmosaic-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/, Preact, Vite). Owns everything else: game state, the append-only JSONL log, the MCP endpoint (core @modelcontextprotocol/+server @modelcontextprotocol/, as in mcpsweeper), SSE to the viewer, and the viewer itself as one island with a canvas.hono
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.
- 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). - Band uniqueness: reject a tile whose row set collides with another tile in the same band (cheap set check; retry the fill).
- Givens: run puzzletea's clue removal per tile but stop at
countSolutions(limit=m+1) == minstead 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 themsolutions differ on at least one seam line. - 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.
- 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 recordsforced | 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 aregiven; unforced commits areguess. 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
solvedwhen every cell is committed and correct. - Every accepted call appends one
batchevent. Refusals append nothing.
MCP (site/ routes/ mcp)
Streamable HTTP at http:/, 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:/, 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 7so 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/is the reference rendering (mock-mosaic.py 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).
genand the viewer both work atX=2andX=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:
- Chunkwise generation (6a).
gen --contextaccepts prefilled cells with their owning tiles; afieldsubcommand 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. - 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.
- Agents in the log. A
joinevent withagent(machine id or a provisional one), harness from MCPclientInfo, reported model, and tags; every write event carriesagent.start_mosaicbecomesjoin { 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. - Per-agent frontier and territory. An agent reveals only tiles
overlapping tiles it solved, until its region touches another's;
regions merge; a
meetevent 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. - 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. - 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).
- Replay timeline. Event-density timeline, markers (tile solved, meet, guess runs), playback speed, follow-agent. Proof: the demo.
- 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.
- 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.
- 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, /),
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):start_mosaicblocks 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,observereportsgeneratinguntil ready, andlist_gamesshows it; expire never-joined games.- No
Mcp-Session-Idis returned andnotifications/holds the SSE stream open instead of returning 202.initialized commitrejections carry{id, reason}only: echo the rejected value, and say in the tool text that a wrong binary value is full information.- Document the referee's propagation exactly (the agent's pair/gap + balance + distinct-lines propagation found 2 fewer forced cells than the referee's).
- State that a solved neighbour does not always pin a tile (tiles 9, 15, 27 kept 2 solutions with one solved neighbour).
observe {agent}lists the shapes andmof 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-scopedobserve(28 KB on a 2203-tile synthetic field), analyzer in the fold withendcarrying it and/; 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: loglog/ :id?summary=1 46401331-.... Harness capture:clientInfois not available per call in this SDK's stateless mode; the site recordsharness_source(envelope | client_version | argument | none). - Fresh-agent feedback fixed (site core):
start_mosaicis asynchronous (generatingstatus, 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 issueMcp-Session-Id;notifications/already returned 202 (the agent misread an SSE-framedinitialized 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/fieldand 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,
Assumeper 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.
- 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 agrowevent (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. - Fog. With
rules.fog,observelists 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 agrowmarker on the timeline. Proof: a test that observe never lists an unreached tile; screenshots of reach spreading over a grown field. - Daily loop.
deno task daily: at rollover archive the day (manifest, log, analysis summary underdata/, index row in SQLite), start the new day's field fromdays/ <date>/ 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),/the replay,day/ <date> /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 oncalendar observe; a fetch timeout in the scripted client. - 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.devdomain 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. - Machine identity and shoo.dev (from the identity decision):
machine keys at join,
/pairing, "yours" on login. Last; optional for the first hosted days.link
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 daytimegrowevent 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 judgeto 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 1000centres the cluster;field --grow --stategenerates the next ring against the border as fixed context and emits a delta;--rings N|allpre-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 asgrowevents with no daytime engine call (on-demand--growonly as fallback); fog inobserve(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 replacesendwhile 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,
growmarker, "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 screenshotsoutput/.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.devstill answers) — appmosaic-daily, region ams at first, one shared-cpu-2x machine (2 GB), 1 GB volume at/, TLS; about $11/month at list prices.data 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/with a bearer token (push/ <date> ~/, Fly secret.config/ mosaic/ push-token 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/, SQLite index;days/ <date>/ /,/,day/ <date> /,calendar /(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.leaderboard/ <date> - Robustness: the referee re-folds from the log on restart (proven
locally at rev 280 and hosted across
fly machines restartat rev 179, both resumed and played on);observecapped at 100 tiles with atruncatedflag; client fetch timeout 90 s. - Proof:
tests/(two simulated rollovers with winners, honours, cumulative boards, restart resume, missing-day behaviour); hosted daydaily_test.ts day-2026-08-27played by two scripted agents. - Not yet: a fresh agent from another machine (item 14's proof line),
the nightly
--rings alltiming, 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 / 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_mosaicwas 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;
joinwith 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 /, 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; / 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/: build the engine in
golang:1.27, run push-day --tomorrow --rings all in denoland/,
token from a repo secret) pushing to the app at 05:00 UTC; the repo
lives at FelineStateMachine/ (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/ 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 / 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 withjoin(the one tool every agent calls), every tool points at themosaic:/resource,/ rules observedefines 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/): 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/, buggy, a statement of intent, not pixel truth). Implement it
in the real viewer. Done means all of these, each proven:
- Identity tokens.
lib/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.colour.ts - 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.
- 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.
- Counting line along the board's bottom edge: per-agent points live, leader bold, dead struck through.
- Scrubber and playback stay under the board as a slim rule with kifu-notated markers; the move list follows the playhead.
- Day end. A result card in kifu style over the tournament table;
/andday/ <date> /in the identity (calendar as day cells with a winner bar; leaderboard as the table).calendar - 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.
- Proofs held:
deno task testandcheckgreen, 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
~/ (ed25519, machine_id = m- +
sha256(public key)[:16]) from deno run -A https:/;
join carries {machine_id, public_key, proof} with proof a
signature over mosaic-join|game|key|minute (current or previous
minute); TOFU registry data/; agent id
machine_id/ 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 (/) wholesale —
identity file as JWK ed25519 in the tribes shape, key-auth message
mosaic-key-auth:<host>:<ts>, POST / mints a bearer
token (one per key) that joins carry; any ed25519 tool can mint the
key (Node snippet, openssl, Python; spec at /), 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 (/, 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/ (or name/) 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/ serving the fogged live view on / and
/ 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 /, labels
name/). Regression tests: tests/,
tests/, 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/: 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 /
(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/. 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/). 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 /: 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:
- Real packed run.
mosaic-engine gen --G 100 --kind big-firstis 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/. Proof: a game log undermcp site/whose manifest isdata/ games/ packer.kind: big-first, plus the existing live-fold == replay-fold test run over that log. - Run tags.
start_mosaicacceptstags { model, batch, code_use, initial_reveal, note }; they are written as arunevent 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)./lists games filterable by tag and by seed commit. Proof: a test that two games of one challenge sharegames manifest.idand differ inrun. - Analyzer in the fold. Every batch event folds into
analysisfields (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 testasserts analysis equality at every revision between live and replay, and classifies the scripted client's BFS and DFS modes correctly. - Paired trials. A
challengeis a seed commit;start_mosaic { challenge }joins it (same cached generation). The viewer at/renders both games on one scrubber, aligned by revision, with the analyzer pane. Proof: two scripted clients (BFS vs DFS,?compare=a,b --limit 6vs--limit 2) on one challenge produce two logs the compare page renders and the analyzer tells apart. - Coupling depth
kmeasured. Per tile,kis 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). Mosaickis the maximum, and the manifest carries the distributionk_hist. Replacesk: unmeasured. Proof:go test ./re-derivesgen kfrom the emitted alternatives and asserts every tile has1 <= k <= segments. - Tile library.
site/with the schema indata/ library.sqlite docs/§5;space-and-cache.md mosaic-engine lib addingests gen output (one canonical orientation per tile, all 16 edge tuples indexed);mosaic-engine stitch --seed --G --kindresolves a packing against the library, records misses, andgen --draingenerates 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.libraryis the file hash. - Envelope.
packat G = 1000 in under a second with zone share under 3%;stitchat 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.
kis a number in every manifest, and at least one tile in the played board hask >= 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/; every item passed.report/ index.html - No packed (big-first / bands) board had ever been generated for real:
site/synthesises the big-first fixture (random solution, random givens, judge stubbed), and every log undertests/ fixture.ts site/was a grid. The previous session's attempt to play a G=60 big-first board ran 24 minutes and died in generation.data/ games/ - 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.Versionisgen-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)undersite/.data/ cache/ - First real packed run: G=60 big-first, seed
a, played by the scripted client through/; logmcp site/, 183 revisions, 62 reveals, 120 commits, 1583 forced / 797 guess / 11 wrong, reward 4232 (= sum of n^2), replay atdata/ games/ 48edade0-...jsonl /.?replay=48edade0-... deno task testnow 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 longstart_mosaic(that was the previous session's crash). - G=100 big-first, seed
a, same client: logbb7b5f61-...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 devprints the prompt; usekind=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:Windowis replaced byShape {X0, Y0, W, H, Rects}; aBoardcarries shapes and precomputes their lines; every rule, propagation, flip rectangle, ladder rating and the coupled CSP works on lines and shared cells.satencodes 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 ../'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 (scatterwith a hole-fill pass and the distinct-lines bound), engine core (ruleson shapes and lines,satper 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.ValidShapeandrules.Shape.Validboth 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-..., screenshotoutput/. 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; logplaywright/ v3-scatter-60-overview.png 7b8941d3-..., screenshotoutput/.playwright/ v3-scatter-100-overview.png - Engine-core fork final:
go test ./green (... gen130-154 s with both fixtures; the 2x2 reproduces byte for byte underMOSAIC_SLOW=1); pins are now recomputed at settle time; a layout is validated withShape.Valid()before any search (an invalid shape cost SAT 12-20 s to refute). Manifest deviation from the contract:rulesfields areoverlaps,cross_tile(notseams,cross_seam);Output.Layoutcarries 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
satmutex), so onegenuses 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), achunksorchestrator in the site (lib/) or aengine.ts mosaic-engine fieldsubcommand 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:
observescoped 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.
- 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), agrowevent, the viewer's geometry accepting new tiles mid-log. - 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
terrainparam on the packer, per-tile policy overrides in the manifest. - 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
contestedflag in observe, analyzer fields, viewer marker. - 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
notetool and event. - 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
rulesandsat). Agents must read the mask and adapt their solvers; specialists emerge. Needs:rulesper tile in the manifest and the judge, aMaskextension. - 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
rulesandsat, a manifestlinks[], a viewer mark at both ends. - Shapes unknown until adjacent.
observelists hidden tiles only once a group's tile overlaps them; exploration has a cost. A flag. - 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 andm(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/, 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.daily.Seed(date) - 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"./the replay of any past day, with the same viewer.day/ <date> /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.calendar /claim a machine token (identity decision above).link?t=...
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:
- The agent generates its keypair, registers the pubkey with the API, and
gets back a
machine_id. - 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.
- The agent opens the default browser to
https:/(and prints the URL as a fallback for headless/SSH)./ <site>/ link?t=<token> - The server validates the token's signature chain, ties the session to
machine_id, and sets a long-livedSet-Cookie. - 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:/(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/ <domain>/ link?t=<token> 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
/exists; the shoo.dev integration details (OIDC or its own flow) are to be read from its docs when this is built, not assumed.link
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/.
- 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 fromtiles[](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;
pinsare 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/, the pair mcpsweeper already runs, mounted inside Fresh.hono - 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.