Home › Weather › Samon: Designing a Zen Garden Raking Puzzle
Weather

Samon: Designing a Zen Garden Raking Puzzle

Key Points

Zen sand gardens are often intended for meditation, whether to look at or while maintaining it by raking the sand into the prescribed patterns which are formed by the tines of the rake yielding parallel lines (like ||| ). Such a chore might be tedious, but there is also a great deal of satisfaction to restoring an esthetic order from chaos, and raking sand seems like one of those things, like popping bubblewrap, which can be oddly satisfying—I recall the lifeguards at my summer camp seeming...

Zen sand gardens are often intended for meditation, whether to look at or while maintaining it by raking the sand into the prescribed patterns which are formed by the tines of the rake yielding parallel lines (like ||| ). Such a chore might be tedious, but there is also a great deal of satisfaction to restoring an esthetic order from chaos, and raking sand seems like one of those things, like popping bubblewrap, which can be oddly satisfying—I recall the lifeguards at my summer camp seeming to rather enjoy the task of raking the beach in front of their office every day and not allowing any campers to do so. Puzzle-solving is also often described as being ‘meditative’ and ‘oddly satisfying’, even when it looks a lot like a tedious chore. So for those of us who do not live at a Zen temple, can we make a Zen sand garden puzzle? This page turns the original 2023 sketch into a full design. Where the sketch speculated (would “mirror raking” be too restrictive? could a neural net rank solutions by beauty? how should difficulty be raised?), we answer with a solver and measurements; where it left gaps (what exactly makes a garden hard, what the worlds are, how levels are generated, what the player touches), we fill them. The title is Samon (砂紋, “sand crest”), the term for the patterns themselves.1 The Garden Pieces. A garden is a walled rectangle of white gravel—in Kyoto, typically Shirakawa-suna, weathered granite from the Kitashirakawa area (Nabunken)—divided into square cells, with stones as obstacles. One cell on the veranda side is the gate: the gap in front of the kutsunugi-ishi, the flat stone below the veranda where one removes one’s shoes (JAANUS). The player is an unsui, a novice monk, and the garden is viewed, as at Ryōan-ji, from the abbot’s veranda: the bottom edge of the screen. The rule. The novice steps off the shoe-stone, walks into the sand, puts down his rake, and walks backwards to the gate, dragging the rake over his own footprints. The garden is finished when every cell of sand has been raked exactly once, the lines are unbroken, and he is back on the shoe-stone. That is the whole rule: one stroke, every cell once, no crossing, ending at the gate. Formally: Hamiltonian paths. Formally, the sand cells form a grid graph, and a valid raking is a Hamiltonian path of that graph which ends at the gate cell. The rake lines are the path’s shape: a cell traversed straight gets parallel grooves; a cell where the path turns gets concentric quarter-circles; the cell where the rake was set down gets the groove’s start. Every claim below about counts, pars, and difficulty comes from an exact solver for this formulation (see Methods). Walking in is free. The original sketch distinguished the forward walk (leaving footprints) from the backward raking, and proposed “mirror raking”—the monk retraces his walk exactly—as a possibly-too-restrictive simplification. It is not a restriction at all. Any walk in, followed by a raking that covers every cell exactly once and ends at the gate, erases every footprint, because every cell gets raked; so the walk in is irrelevant except for where it ends, and the set of finished gardens is exactly the set of Hamiltonian paths ending at the gate. Conversely, every such path is achieved by walking it in and raking it out.2 So mirror raking loses nothing, and the interface can be the simplest possible: draw a path from the gate; that path is the walk in; the raking is its reverse. This matters for design because the story stays intact (the reveal is the monk raking himself out of the garden) while the player’s task is a single, legible gesture. The Bare Rule Is Loose Too many rakings. The one-stroke rule alone is a weak constraint in open sand. For an empty garden with a corner gate, the number of valid rakings is the number of Hamiltonian paths from a corner, OEIS A145157, which grows explosively: 824 for 5×5, 1,510,446 for 7×7, 54,986,690,944 for 9×9. (Our solver reproduces A145157 and the 5×n sequence A145156 exactly, which is a useful correctness check.) Anyone can rake an open garden: walk up and down in rows. | Garden | Valid rakings | Par (min. turns) | Rakings at par | Median turns | Max turns | Wide-rake rakings | |---|---|---|---|---|---|---| | 3×3 | 8 | 4 | 2 | 5 | 6 | 2 | | 4×4 | 52 | 6 | 4 | 8 | 13 | 4 | | 5×5 | 824 | 8 | 10 | 13 | 20 | 4 | | 6×6 | 22,144 | 10 | 24 | 20 | 31 | 6 | | 7×7 | 1,510,446 | 12 | 66 | 27 | 42 | 6 | | 6×8 | 899,432 | 11 | 66 | 26 | 42 | 8 | Parity. Stones make the rule bite, but mostly by making gardens impossible. Color the cells like a checkerboard: every step of the stroke changes color, so a stroke over n cells alternates colors, and if one color has more cells, the stroke must begin and end on it. A 5×5 garden has 13 dark and 12 light cells, so its gate must be on a dark cell; move the gate one cell along the wall and there is no raking at all. Of random 7×7 gardens with a gate somewhere on the veranda wall, 57% pass this parity test with no stones and only 31% with 6 stones. Random stones: unsolvable or open. Past parity, stones create dead ends (a cell with one free neighbor must be where the rake is set down—at most one such cell may exist) and corridors (a cell with two free neighbors must use both). These are the classic deductions of one-stroke puzzles, and they are real, but random stone placement produces them in the wrong proportion: gardens are either unsolvable or still have thousands of rakings. In 300 random 7×7 gardens per row, none with 0–6 random stones had a unique raking: | Stones | Parity OK | One stroke: solvable | One stroke: unique | One stroke: median rakings | No U-turns at all: solvable | Wide rake: solvable | Wide rake: unique | Wide rake: median rakings | |---|---|---|---|---|---|---|---|---| | 0 | 57% | 57% | 0% | ≥100,000 | 0% | 57% | 0% | 6 | | 2 | 43% | 42% | 0% | 28,326 | 0% | 18% | 2.7% | 2 | | 4 | 34% | 20% | 0% | 610 | 0% | 2.7% | 0.3% | 3 | | 6 | 31% | 8% | 0% | 96 | 0% | 0.3% | 0% | 4 | The bare rule therefore cannot carry a game by itself: completion is trivial in open sand, and the obstacles that make it non-trivial mostly make it impossible. Two things fix this: a score that makes the slack matter, and rules that remove it. Pleasingly, both come from what makes a raked garden beautiful. Calm: Turns As Score The abbot counts turns. The commonest samon are long parallel lines, interrupted where they ring a stone. A raking that zigzags looks agitated. So the game’s single esthetic metric is the number of turns: every cell where the stroke changes direction. Fewer turns is calmer. Each garden has a par, the minimum number of turns over all valid rakings, proven by a constraint solver (in a few seconds at most, for gardens up to 9×9). Any valid raking finishes the garden; a raking at par earns the abbot’s vermilion seal. Par is a needle. Turns make the looseness useful. The distribution of turns over all valid rakings is a broad hump, and par sits in the far tail: in the empty 7×7 garden, 66 of 1,510,446 rakings (0.004%) reach par, and the median raking has more than twice as many turns. Finishing a garden is easy; finishing it calmly is a real optimization puzzle, of the kind where intuition (“make long lines; circle the stones; use the walls”) gets close and the last two turns take thought. Histograms. This is the structure of Zachtronics optimization games, where after solving, the player’s solution is ranked against every other player’s on a few metrics (Opus Magnum compares cycles, cost, and area). Samon shows one histogram per garden after completion: the player’s turn count against everyone’s, with par marked—a picture very like the one above, filled in by real players. Because par is proven, the seal is never a trap (no “par” that nobody can reach) and never a lie (no raking can beat it). Par tends to look traditional. The surprise is that minimizing a single number recovers the traditional motifs. In the open and lightly-stoned gardens we inspected, par rakings were nested U-bends and spirals: long parallel lines across the garden (sazanami), and concentric rings around stones (mizumon), without either being specified. The rings appear because circling a stone is the cheapest way to use the cells next to it; the parallel lines appear because every turn costs. This is not a law, though: with crowded or awkwardly-placed stones, par rakings degenerate into meanders full of tight U-turns in open sand. Among 60 random solvable gardens (6×7–7×8, 1–3 stones), only 40% admitted any raking free of open-sand U-turns, and only 20% had a par raking that was free of them; so “par is beautiful” is a property for the level curator to select for (and we do: see Generating Gardens), not something the rule guarantees. Why not a neural esthetic score? The sketch proposed ranking solutions with a learned esthetic model, such as the CLIP-based predictor behind LAION-Aesthetics. For scoring the player, a hand-countable metric is better: the player can see it, count it, and deliberately optimize it, and a proven optimum exists; an opaque neural score can be none of these, and would invite adversarial raking. The learned model is better spent upstream, ranking layouts (which stone arrangements look like real gardens, which par rakings are prettiest when several tie), where opacity costs nothing. Mechanics One rule per world. The standing criticism of Zen Puzzle Garden, the closest ancestor, was monotony: “there’s still a persisting feeling that, even by the final few stages, Zen Puzzle Garden hasn’t developed one step beyond what the tutorial already demonstrated” (Wilson 2011). So each world adds one rule, and each rule must earn its place by changing how the player thinks, not just the scenery. Every rule below is diegetic—a thing in the garden or a property of the rake—and every one was tested with the solver on random gardens before being kept. Here are the candidates that survived, in the order the game introduces them, and then the ones that did not. Ripple Stones Rings. A ripple stone (marked with faint concentric rings in the abbot’s sketch) must be encircled: its 8 neighboring cells must be raked as one unbroken arc, entering once and leaving once. This is mizumon, the rings-around-an-island motif, as a hard constraint instead of an esthetic tendency. Texture, not tightness. The surprise of testing was how little it constrains. In the garden below, the ripple rule cuts the number of valid rakings from 183,986 (with an ordinary stone) to 35,570, and leaves par unchanged at 12; 4 of the 5 par rakings already ringed the stone. Across random 7×7 gardens with one ripple stone (and 0–2 plain stones), the median number of rakings was still 5,573. Ripple stones teach “rings are entered from outside and left from the cell next to the entrance” and make the reveal prettier, but they cannot make a garden tight on their own. Wave Bands Lines that run through. A wave band is a hatched region of the sketch: inside it, every row (or column, for a vertical band) must be raked as one straight, unbroken run, edge to edge. This is sazanami: the long parallel ripples that most gardens are mostly made of. Mechanically, each row of the band becomes a corridor that must be crossed in one go, so the player must plan where the turnarounds go outside the band. Somewhat tighter. Bands constrain more than rings: in the garden below, the band cuts 1,492,008 rakings to 2,984, and raises par from 16 to 17 (the calmest raking of the bare garden does not run its lines where the abbot wants them). But across random 7×7 gardens with a band (and 0–2 stones), the median count was still 3,799. Ichimatsu. Alternate horizontal and vertical bands in a checkerboard and you get ichimatsu. Here the band rule finally becomes tight, because every cell now belongs to a run and the stroke can turn only at run ends, handing off from one run to the next: the 9×9 garden of nine 3×3 blocks below has exactly 2 valid rakings, versus 54,986,690,944 for the empty 9×9 garden, and the 6×6 version (four blocks) has exactly 1 for most gate positions. These make good end-of-world showpieces: a real checkerboard samon, drawn in one stroke. The Master’s Grooves Continue his lines. The abbot has begun the raking and been called away: a few cells already carry grooves, and the finished garden must be one stroke that runs along them. (Re-raking a groove in its own direction leaves no trace, so the novice may pass through these cells, but only following them.) These are the “partial raking” of the original sketch, and are the game’s general-purpose clue: unlike abstract numbers or symbols, a given groove is self-explanatory, since it is a piece of the answer. Uniqueness for ~5–8 grooves. Grooves are how free gardens become strict ones. The level generator picks a beautiful raking as the intended answer—a par raking with no U-turn in open sand—and adds grooves from it until it is the only valid raking left. In the garden below, 5 grooves cut 12,375 rakings to 1, and the answer is a par raking (21 turns; 1 of 11 at par). Over 69 generated 7×7–7×8 gardens, the median was 7 grooves. Two kinds of groove. The abbot can also leave finished work: a closed ring raked around a stone, as real gardens have. A single stroke can never close a ring, so these are effectively obstacles—and a way to put the iconic closed ripple into gardens without violating the one-stroke rule. They are also the “partial raking which makes an insoluble problem soluble” of the sketch: pre-raked cells are removed from the stroke’s job, which changes the garden’s topology (and, if the finished shape covers unequal numbers of dark and light cells, its parity). The Abbot’s Pebbles Straight or turn, nothing more. A weaker clue than a groove: the abbot leaves a pebble on a cell. A pale pebble means the stroke runs straight through that cell, across or down; a dark pebble means it turns there, either way. A pebble’s cell is never where the rake is set down, since that cell has only one connection. (The “any turn” clue comes from a parallel design by GPT-6 Astra; Nikoli’s Masyu uses white and black pearls the same way, with extra conditions on the neighboring cells that pebbles do not have.) More clues, harder deduction. We made 88 generated 7×7–7×8 gardens unique both ways, from the same intended raking (a graceful par raking) by the same greedy procedure. Grooves needed a median of 7 clues and pebbles 11 (more in 92% of gardens), but the pebble gardens were much harder: 92% needed lookahead by our grader (median 7 steps) and 12.5% nested lookahead, against 47% (median 2 steps; 58% in the pipeline’s separate sample, below) and none for grooves. A groove gives away a piece of the answer; a pebble is a disjunction to be resolved against its neighbors, which is the kind of deduction the strict worlds want. So pebbles come after grooves in world 4, and supply the hardest strict gardens of world 7. Below, 8 pebbles leave exactly one of a garden’s 59,498 rakings; the same answer needs only 6 grooves, which make the garden tier 1, where the pebbles make it tier 3. The Wide Rake No U-turns in open sand. The novice is promoted and given the senior’s rake, which is too wide to pivot. With it, the stroke may turn 90° anywhere, but it may make a tight U-turn (two consecutive turns the same way) only when the U is braced against a wall or a stone; and the rake must be set down in a cell touching a wall or a stone, never in the open. The abbot’s finished sand (his closed rings, above) is neither: turning against it would rake it. A reversal in open sand therefore needs something inside it: a stone to wrap around, or a nest of wider U’s around a stone. The esthetic motivation is the same as for counting turns: a groove that doubles back on itself in open sand is the ugliest thing a raking can contain, and in real gardens lines end at walls and stones, not in open gravel. It tightens everything. This one physical rule does what the pattern constraints could not. In empty gardens it leaves 2–8 rakings out of up to millions (see the first table); in random 7×7 gardens with 2 stones, 18% are solvable and the median solvable garden has 2 rakings; uniqueness, which the bare rule did not produce in any of the 1,200 random 7×7 layouts, appears by itself (2.7% of 2-stone layouts). Forbidding every U-turn, braced or not, is too much: no empty garden of any size we tried, and none of 1,200 random 7×7 gardens, could be raked at all. Often the calm answer. The striking result is which raking survives. The 8×8 garden below has 6,310,226 valid rakings under the bare rule; 12 of them reach par (17 turns); exactly one of those 12 has no U-turn in open sand; and that one is the garden’s only raking under the wide rake. This is not a fluke of one garden, though it is not a law either: over 964 random gardens with a unique wide-rake raking, that raking was a par raking of the unrestricted garden in 32% of cases, and in 47% of the calm-looking ones (turn density ≤0.40) that the curator would keep. So the novice’s tool often selects the very raking the abbot would have chosen; but it is a stricter rule than his taste, not the same one (see Free, then strict). Hard. Wide-rake gardens are the hardest in the game by our grader: every one we generated required lookahead (assume a move, propagate, find a contradiction), and about a quarter required nested lookahead. The player-facing heuristics—“every reversal needs a pivot”, “count the cells inside each U”, “lines run wall to wall”—are learnable, but this needs playtesting more than anything else in the design, and is why it comes fifth rather than second. The Side Gate Both ends fixed. Some gardens have a second, side gate, and the novice must rake his way in from it (walking backwards from the start, so leaving no footprints at all) and out by the veranda. Fixing both ends of the stroke doubles the force of parity (both endpoint colors are now given) and removes the player’s freedom of where to begin. The stroke comes in through the wall, so a bend in its first cell counts as a turn, as leaving by the veranda gate does. Under the bare rule this is still loose (the garden below has 311,080 such rakings); combined with the wide rake it gives a unique, graceful answer that is also par. Rejected Mechanics Mechanics we considered and dropped, with the reason for each. - Ice-sliding: Zen Puzzle Garden’s rule (the monk slides until blocked, and may only turn when blocked) is the obvious alternative core. It is taken, and it makes the puzzle about stopping places rather than shapes. - Braced turns only (90° turns allowed only next to walls or stones): a softer cousin of ice-sliding. Too strict: only 31% of empty 7×7 gardens remain solvable, and almost no gardens with stones. - No U-turns at all: infeasible; see above. - Pushable lanterns, ordered leaves: Zen Puzzle Garden’s later additions (JayIsGames). They import Sokoban into a game whose identity is the line. - Repairing a disturbed garden (a deer walked through overnight; re-rake only what you must): it sounds rich but is trivial. For the repaired garden to be one stroke, the grooves the novice leaves untouched must be an initial segment of the old stroke, ending where his new raking begins; so the cheapest repair is always to re-rake everything from the first damaged cell (in the old stroke’s order) back to the gate, and the route he takes doesn’t matter. - Multiple monks / multiple strokes / stepping stones that lift the rake: each adds slack to a rule whose problem is already slack. - Bridges over a dry stream (the only cells the stroke may cross): workable (Flow Free: Bridges does it), but untested here, and it weakens “never cross your own lines”, the rule that makes the fiction work. - Symmetric gardens (mirror-image halves raked in mirror image): against the grain of the esthetic, which prizes fukinsei, asymmetry (Japanese aesthetics), and impossible anyway. A symmetry of the garden that preserves the stroke maps the path onto itself, and a path’s only symmetries are the identity and reversal; reversal would swap the gate’s cell with the rake’s start, so it must be the identity, which fixes every cell of the stroke: all the sand would have to lie on the mirror line (Astra’s argument). Local symmetry, two marked patches raked in mirror image, is possible; Astra proposes it as an optional rule, untested here. Progression Seven worlds, one day. The worlds follow one day at the temple, dawn to moonrise, which gives each a lighting (and so a look: low sun makes grooves cast long shadows; noon flattens them; moonlight silvers them) without any text. Each introduces exactly one rule; gardens grow from one-cell corridors to 9×9. | World | Time | New rule | What it teaches | Gardens (main path + extras) | |---|---|---|---|---| | 1. First Stroke | Dawn | One stroke from the gate; stones | A dead end is where the walk in must end; corridors force the route; parity | 9 + 10 | | 2. Ripples | Morning | Ripple stones | Rings: entered once, left beside the entrance | 10 + 10 | | 3. Waves | Noon | Wave bands; ichimatsu | Runs are atomic; plan turnarounds outside them | 11 + 9 | | 4. The Master’s Grooves | Afternoon | Given grooves (open & closed); then pebbles | Continuation; reading a unique answer; resolving “straight or turn” against the neighbors | 16 + 9 | | 5. The Wide Rake | Evening | No U-turn in open sand; set rake down at an edge | Every reversal needs a pivot | 9 + 17 | | 6. The Side Gate | Dusk | Two gates | Both ends fixed; double parity | 9 + 9 | | 7. Silver Sand | Night | All of the above, larger gardens | Synthesis | 8 + 12 | Free, then strict. The arc runs from optimization to deduction. In worlds 1–3 any valid raking finishes a garden and the seal rewards calm; the player learns, by seeing their own rakings next to par, that calm rakings are long lines and rings. From world 4 on, gardens have one valid raking, and the rules (grooves, then the wide rake) enforce a calm that the first half only rewarded. It is not quite the same calm. The generator keeps a free garden only if some par raking has no U-turn in open sand, but par is usually reached by others too: in 42 of the 54 free gardens of worlds 1–3 the par rakings include both kinds, and 327 of all 407 par rakings there turn back in open sand somewhere (Waves, for instance, has 34 par rakings, and 32 of them do). So the wide rake is a stricter rule than the abbot’s taste, to be learned as a new rule, not the taste made physical (Astra’s correction: an earlier version of this page claimed the latter). Curating the free worlds so that every par raking is graceful would leave too few gardens (11 of 54 qualify), so instead the wide rake’s introduction makes the difference visible: its first garden is one whose par raking turns back in open sand (below). The seal works the same way throughout; in a strict garden every valid raking is at par, so there it marks a garden raked without the abbot’s help. The final world is named for the “sea of silver sand” at Ginkaku-ji, raked “so that in moonlight its ridges appear as ripples in water” (Cartwright 2019). A short main path. Each world has a short main path, which is the template below and nothing more (8–16 gardens; 72 in all), followed by optional extras (76), listed after it as “more”: the rest of the generated gardens, for practice and for seals. In the first version of the campaign every garden was on the path, so grooves came after 38 gardens and the wide rake after 52 (Astra’s count, from the unlocking rule); now they come after 21 and 32. Opening worlds. A world opens once two-thirds of the main path of the world before it has been raked, at any number of turns; extras raked instead count too. Par is never required, and any garden can be finished, by persistence or with the abbot’s help, so no player is ever locked out; within an open world every garden is open, so a player stuck on one rakes others. The first five gardens. Teaching is wordless, by construction: (1) a one-cell-wide corridor (drawing, and the reveal); (2) a 3×3 garden with a corner gate (more than one answer, and U-turns at the walls; the turn counter and par appear); (3) the first real garden, the easiest of world 1’s generated ones (5×5, one stone), where long lines turning at a stone first appear; (4) a garden with a one-cell pocket behind a stone (the pocket can only be where the rake is set down); (5) a garden of two-tone gravel in which the obvious place to end the walk is the wrong color, so every attempt ending there strands a cell (parity, made visible; the two tones fade to uniform white over the next two gardens). The real garden comes before the two techniques, which only matter at its size: an early player, on a phone, did not “sense the potential until about six levels in”, which is where the first real garden then came. A 2×3 garden that taught the U-turn was dropped to make room; the 3×3 garden’s par rakings turn back at the walls anyway. Inside a world. Each world after the first follows one template (Astra’s): an introduction (a contrasting pair, below); two applications; a garden whose obvious move, learned from the gardens before it, is wrong; two combinations with earlier rules; a calm, easy garden; and a capstone. Every garden is stored with its intended raking, the one observation it teaches, and the abbot’s first hint, which states that observation; the test for keeping a garden is what fact changes the player’s plan? A garden is cut if the only way to solve it is trial and error, or if its clues trace the answer outright. Show the change. Each later rule is introduced by a contrasting pair (also Astra’s suggestion): a small garden raked as usual, then the same garden with one change, both found by search. A ripple stone, a band, or the wide rake is added to a garden whose every par raking breaks the new rule, so the plan that just worked visibly fails; one groove cuts a garden’s 8 rakings to one; pebbles, one pale and one dark, replace the two grooves that pinned another garden down; a side gate leaves one of a garden’s two wide-rake rakings, and not the par one. The pairs have 17–23 cells of sand; the first campaign introduced bands in a 55-cell garden, and grooves and pebbles in 47-cell ones, which now come after two smaller applications. The changed garden of each pair is demonstrated before the player rakes it (see Interface). The campaign. The prototype has all 148 gardens. Thirty-four are teaching gardens: 13 of the prototype’s original 14 (the 2×3 garden was dropped), the corridor, three ichimatsu variants, four small introductions found by search (among them the two-tone parity garden), and the 13 gardens of the contrasting pairs. The other 114 come from the pipeline below, with garden-like stones: for each size and mechanic, it generates up to twice as many candidates as needed (235 from 16,437 sampled layouts, in 33 minutes of computing) and keeps a subset spread over their difficulty range, no two alike up to rotation and reflection (apart from the pairs, which are alike on purpose). Half of that time went on failing to find a single 9×9 pebble garden (none of 227 layouts passed the filters), so world 7’s pebble gardens are 8×8. Ordered by the template. Each world is put in the template’s order, with every role assigned by measurement. Applications run from easy to middling (the hardest are extras): free gardens by size, then by how rare par is among their rakings; strict gardens by grade. The trap is the garden whose obvious move is most clearly wrong: in a free garden, the one whose key observation (below) the near-misses, rakings one or two turns over par, most often get wrong (between 74% and 94% of them, in worlds 1–3); in a strict garden, one whose only raking turns aside at once, where walking straight in from the gate can be refuted only by lookahead. The combinations add a rule from an earlier world (a second ripple stone; a ripple stone among bands; grooves or pebbles with a ripple or a band; the wide rake with either; pebbles at a side gate), generated for the purpose. The calm garden is the one with the fewest turns per cell among the easier half of the rest, and the capstone is the hardest, or a hand-made showpiece (Calm, Spiral, Side gate, Silver sand). A rule introduced mid-world (ichimatsu; the abbot’s finished rings; his pebbles) gets its own short run of gardens after the trap. Hints that change the plan. Each generated garden’s first hint is the one provable fact that most changes the player’s plan, and the abbot points at the cells it names. In a free garden, it is the fact true of every par raking (where it ends; how it begins; a row, column, or stretch of one raked in one straight line; whether the line turns or runs straight in some cell) that the most near-misses get wrong: a median of 50% of them. In a strict garden, it is the fact about the only raking (where the rake is set down, or the line’s shape in one cell) that most lowers the grade; when the rake is set down in a cell with one way in, the hint says so, giving the reason before the place. Of the 80 strict gardens with such a hint, 62 need lookahead without it; with it, 16 need none, the 10 that needed nested lookahead need only one level, and the other 36 need fewer steps. Of the 18 that need no lookahead anyway, it lets 4 be solved by local rules alone (the other 14 already are). In a strict trap, the observation is the trap itself: walking straight in goes wrong. None of this is curated or playtested: it is the raw material for step 3 of the production plan, not its result. Daily garden: ichi-go ichi-e. One generated garden per day, the same for everyone, alternating free and strict. The player may draw and undo freely, and here nothing is raked until they press Rake (the only garden with the button); that first raking is the day’s record—“one time, one meeting” (ichi-go ichi-e). The result shared is the seal (or not) and turns versus par, with the garden’s shape but not the stroke, which spoils nothing (everyone else is still raking it that day). The prototype has it: a year of 366 gardens made by the campaign’s generators and never used in it (even days free, odd days strict; 5×5 to 8×8; ripple stones, bands, grooves, pebbles, the wide rake and side gates all appear, with the rule stated in a line, since the daily garden does not wait for the player to reach the world that teaches it), picked by the local date, so everyone gets the same garden on the same day and the year repeats. Drawing and undoing are free; Rake commits; the raking is then final for the day, with the record, the poem (the campaign’s, by day of the year) and a streak of consecutive days shown, a share to pass on (“Samon · daily garden 2026-10-04 · 14 turns, par 12 · 静 · streak 3”, the garden’s shape drawn in box-drawing characters without the stroke, and a link that opens today’s garden: see Sharing below), and a new garden tomorrow. LinkedIn’s Zip, a daily Hamiltonian-path puzzle, suggests the appetite is there. Generating Gardens Pipeline. Handmade levels set the curriculum; generated ones fill it, and supply the daily garden. The pipeline: - Sample a layout: garden size, stones, gate on the veranda wall, and the world’s markings. Stones are placed the way a garden designer would: an odd number of pieces (1, 3 or 5), each of 1–3 cells (single, straight or L-shaped), none within a cell of another, with no mirror or half-turn symmetry, and covering at most ~15% of the garden. (The experiments elsewhere on this page used single-cell stones; the two samplers are compared below.) - Reject on parity (instant). - Count valid rakings with the exact solver (milliseconds for ≤8×8; the empty 7×7 garden’s 1.5 million rakings take 2 seconds). - Prove par with the constraint solver, and also the best turn count achievable with no U-turn in open sand; reject free gardens where the two differ (their par raking is a meander, and the seal would reward an ugly answer). - Strict gardens: keep wide-rake layouts that are unique by themselves, or add grooves or pebbles from the graceful par raking until it is unique. - Grade with the deductive solver (below), and reject or reorder. - Curate the survivors for layout esthetics (a learned model could help here; see above), and write each one’s observation and first hint. Yields. Strictness is cheap to generate. Of 1,151 random 7×7–7×8 layouts, 69 (6.0%) passed all filters for groove gardens, needing a median 7 grooves. Of 95,673 random 7×7–8×8 layouts with 1–3 stones, 2,444 (2.6%) were unique under the wide rake with no grooves at all; 58% of those had a turn density (turns per cell) of at most 0.40, which in practice separates calm answers from meandering ones. At these rates, a year of daily gardens takes on the order of a CPU-hour; the prototype’s year took 8 minutes of generation on two cores (src/daily.py ), plus the campaign’s unused candidates. Garden-like stones cost nothing. Realistic stone groups do not make good gardens rarer. On 1,000 random 7×7 layouts from each sampler (garden-like layouts averaged 4.7 stone cells, single-cell ones 2.0), fewer garden-like layouts were solvable at all (28% against 39%), but the filters discard those anyway: usable free gardens came slightly more often (4.7% of layouts against 3.8%), gardens unique under the wide rake about as often (2.2% against 2.7%), and unique side-gate gardens slightly more often (1.8% against 1.3%). Groove gardens needed the same median of 6 grooves; on small samples (25 each), the garden-like ones needed lookahead less often (28% against 84%), plausibly because larger stones make more corridors, which are local deductions. Grading. Difficulty is estimated by a deductive solver that works like a human, on the state of each boundary between cells (raked through, or not): - Tier 1, local rules: a cell with two raked boundaries closes its others; a cell with exactly two open boundaries uses both; a cell with one is where the rake is set down; no closed loops; grooves, pebbles, bands, rings, and the wide rake’s ban on open U-turns. - Tier 2, global rules: checkerboard parity of the starting cell, connectivity of the unraked sand, and no premature joining of the stroke’s two ends. - Tier 3, lookahead: assume a boundary is raked through, propagate tiers 1–2, and reject it on contradiction. - Tier 4, nested lookahead. Generated groove gardens were 38% tier 1, 4% tier 2, and 58% tier 3 (median 3 lookahead steps); wide-rake gardens were 74% tier 3 (median 16 steps) and 26% tier 4. Within a world, gardens follow the template (Progression), and its strict applications are ordered by tier, then lookahead count, then size. These tiers are a proxy; only playtesting can say whether, for instance, the wide rake’s lookahead is the kind players enjoy or the kind they resent. Interface Show, then do. A garden that brings in something new is first raked once by the monk himself: a ghostly fingertip leads him in from the gate along the intended raking, he rakes his way back out, the result rests for a moment (“That is a calm way to rake it: 3 turns, which is par. Now you.”), and the garden is smoothed for the player. This happens in the first garden (what raking is: in play, the first garden alone left it unclear what to do), in the first one scored by turns, and at the introduction of each later rule; it plays once by itself, Watch replays it, a tap skips it, and it costs nothing. Watching the answer to an introduction spoils little, since an introduction is not meant to be hard; what it teaches is what the rule looks like when obeyed. Draw from the gate. Before the walk, the monk waits in the gateway, with a ring that pulses around him three times and then rests (it pulsed without end at first, which nags once the idea has been had); the player drags him in, and the path snaps cell to cell and leaves footprints. Until a player’s first walk, and always in the first garden, a ghostly fingertip then demonstrates the drag itself, three times; a press anywhere else says where to begin, and demonstrates again. (The prototype first marked the gate’s cell with a dashed circle, which says nothing about where to start, or that one drags.) The walk follows the direction the hand is moving, not the cell the pointer happens to be in: arms are not mounted on tracks, and a hand sweeping along a row drifts off it, so a drag from one end of a row to the other that strays into the next row still draws the row (the first prototype snapped to whichever cell the pointer entered, and a long run would hop rows partway). The axis of motion holds until the motion itself turns to within 30° of the other axis, or until the pointer is two cells off the run; a turn first finishes the run as far as the pointer went, so a rounded corner comes out as a corner; the direction is read over the last 0.4 cell of motion, so a wobble in place reads as nothing; and the pointer is captured, so the grip survives leaving the field or the window, a pointer beyond the walls being read as the nearest cell. (This is the submenu problem of hierarchical menus, solved the same way: the intended movement is what matters, and a cursor that leaves the menu item on a slight diagonal toward the submenu has not left it.) Dragging back over the last footprint retracts it; tapping any footprint walks the monk back to it; tapping the gate starts over. Since either can throw away a long route by accident, the last rewind or restart can always be undone (Restore). A rewind says what it leaves (“Stepped back 3 footprints: par is still possible from here”), replacing whatever verdict stood before, and the garden keeps the keyboard afterwards (after Astra’s note that arrow keys scrolled the page after “Step back 3 for par” and that the old impossibility message lingered). Moves into stones, walls, or footprints are ignored, and so is any move that breaks a rule on the spot (an open-sand U-turn under the wide rake, a turn at a pale pebble), with a soft scrape and a brief judder of the rake; a route that is doomed but not yet broken stays drawable, since noticing the trap is the puzzle. A turn counter and the par sit at the top of the screen; nothing else does. Whatever the game says to the player, as opposed to shows (“Drag the monk in from the gate”, “Morning is open: 20 new gardens”, a locked world’s note, the help, the scroll’s counts) is set in square brackets, as gwern.net sets the editor’s words to the reader; rules, poems, counts and controls are not. Every control, count and mark carries a tooltip with the word more that a hover might want (what Par is and who proved it, what Ask the abbot costs, what a world’s extras are, what the seal says), as gwern.net puts one on everything that would bear it; the garden itself has none, since a tooltip over the sand would follow the hand through every walk. Letting go rakes. When the walk covers every cell and the finger lifts, the monk rakes; from the keyboard, the last step is followed by a beat in which it can still be taken back. Raking is not final: tapping any cell of the raked garden smooths it back to the footprints, as far as that cell, and the walk goes on from there (and Restore rakes it again as it was), so a player who sees 19 turns against a par of 17 backs up with one tap, having seen the pattern. An earlier design asked for an explicit Rake press after every garden (Astra’s); in play it was a confirmation for an action that costs nothing, and it is kept only in the daily garden, where the first raking is the record. Sharing. A raking is worth passing on, and the page has no server, so the raking itself travels in the link: Share this raking gives the share sheet (or the clipboard) the raking drawn in box-drawing characters—light lines for the stroke, heavy for the walls with a tee where the gate breaks them, blocks for the stones—under a line with the garden, the turns, the par and the seal, and a link whose hash holds the raking as a code. The code is small because the rules are strict about what a walk can do next: at each step there are at most three legal moves (and often one), so the walk is written as a number in the mixed radix of its legal moves, step by step in the order north, east, south, west, and a whole raking of a 7×7 garden takes some 20–50 bits (a mean of 22 bits over the campaign’s intended rakings, against 86 at two bits a step; with the garden’s identity and whether the abbot helped, 8–17 characters of base64url). The garden is named in the code by 32 bits of a hash of its layout and rules, never by its place in the campaign, so a link survives a reordering, and a garden changed since is recognized as another rather than mistaken for the one meant (Astra’s point: an 8-bit check, the first version, would have collided once in 256, and a seal must never land on a different puzzle). No code decodes to a walk that breaks a rule, since the decoder re-derives the legal moves as it goes, and a code whose garden is gone or changed, or whose walk does not fit, is refused with a word. Opening such a link shows the garden raked, with its seal, and says whose it is: it is credited to nobody, the viewer’s own progress is untouched, and any tap smooths it away and leaves the garden theirs to rake from the gate (a raking so received cannot be walked back into, or restored). The same idea carries a player’s whole progress between devices without an account: Carry my progress makes a link whose hash lists every raked garden, by the same layout hash, with its state (raked and by how many turns over par, sealed in grey, sealed), the daily records by date with today’s raking, and the unfinished walk of the garden last open, with the abbot’s mark on it: about 7 characters a garden and 4 a day, so some 140 characters for 20 gardens and 2,500 for everything. Opened on another device, it is offered rather than taken (“This link carries a player’s progress: 23 of 148 gardens raked, 19 sealed, 4 daily gardens… Take it over here?”), and taking it joins the two: whichever side has the better state of a garden keeps it, the first raking of a day stays its record, and an entry whose garden is not in this edition, or has changed, is left out and said so, never assigned to the garden now at its place. A link ending in #daily opens today’s garden. (The alternative, a shared raking as its index in a canonical enumeration of the garden’s rakings, would be smaller still for free gardens, but a million-raking garden cannot be enumerated in the browser; the mixed radix is the same ordering, counted from one end, without the enumeration.) Phone geometry. Portrait phones are ≈390 points wide, so a 9-cell garden gives ≈42-point cells, about the 44-point minimum touch target; that sets 9×9 as the largest garden that needs no zoom, and the design never exceeds it. The same interface works with a mouse. The reveal. When the walk is complete, the monk turns, sets down the rake, and walks backwards to the gate, pulling it; then the camera tilts from top-down to the abbot’s view from the veranda, low sun on the ridges, and the seal is stamped or not. The rake must glide: it follows the stroke’s centerline continuously, through every quarter-circle, at a walking pace that eases in where it is set down and out at the gate, carving lit grooves and erasing the footprints as it passes, with a few grains flicked aside by the tines. (The prototype’s first reveal drew finished cells one at a time as line art; it read as chunky and unsatisfying, and the reveal is the game’s payoff, so it is worth the most polish.) It lasts a few seconds, scaled to the garden’s size, and a tap skips it. The abbot judges your route, not his. Asking the abbot never reveals his answer; it decides, by search, what the player’s own route can still become, and says only what it can prove. Gardens this small are decided quickly (the slowest of the prototype’s 4,440 test routes took 0.7 seconds in a desktop browser). There are three verdicts. The route can still be raked at par: he sketches its next groove, taken from a par raking that continues the player’s route. It can still be finished, but not at par: he marks the last footprint from which par was possible and says so with the action first (“step back 3 footprints to the marked footprint for par”), and offers the step. It cannot be finished: he says why when there is a short reason (sand cut off, two dead ends, the wrong count of dark and light cells), marks the cells involved, and offers to step back to the nearest footprint from which the garden can still be finished, proven to be the nearest (if, in a large garden, that proof runs out of budget, he offers a footprint from which it can be finished, without calling it the nearest). In a fresh garden the first request gives instead the garden’s observation (“the top-right corner has only one way in”). If a search ever runs out of budget, he says he cannot tell yet, rather than guess. Help costs the vermilion. The observation is free, and so is the reason a walk has stopped (no step on is allowed, or every cell is covered but the walk can’t end there), since a player can see that much already; an answer the abbot cannot give in time is free too. Anything more—a verdict on a live route, a sketched groove, where to step back to—marks the garden as helped, and a helped raking at par earns the abbot’s seal in grey ink instead of vermilion. Astra argues for making every diagnosis free and charging only for a revealed move; but a verdict on a live route (“this can still reach par”) is an oracle, which a player can consult after every step to search the garden mechanically, and the seal would then certify nothing. The mark lasts until the garden is next raked, so starting over does not wash it out, and it travels with the route it helped: walking back into a helped raking and restoring it rakes it grey again (an early version let Step back and Restore turn a grey seal vermilion; Astra found it). Raking the garden again unaided can still earn the vermilion seal, and the garden list keeps the better of the two (seals are drawn square, rakings off par as round dots). The redemption is deliberate: a player can memorize a helped answer and rake it again, but making one peek cost a garden’s seal forever would punish curiosity more than it rewards honesty. Assists (below) do not affect the seal. This replaces an earlier design, in which a hint revealed one groove of the intended raking; in free gardens that hint contradicted valid routes the player was already on. (Hints that judge the player’s own route, with proven rewinds, are Astra’s idea.) Assists. Off by default after world 1, on toggles: a faint preview of the grooves under the footprints; the checkerboard; and hatching on sand the route has cut off from the gate. There are no timers, move limits, or failure states; an impossible position is simply one the player has not yet noticed. Below the garden. Under the garden sit the turn count against par and three buttons (Step back, Start over, Ask the abbot; Restore, Watch, Rake and Next garden appear when they apply), then the status line, the assists (left in view, since a new player cannot know what an assist is until shown), a garden’s rule where it brings one in, the list of gardens, and the help, folded into one line. The list shows the worlds that are open, each as its main path, with the extras folded behind “+10 more” (unfolded by themselves while one of them is the garden open); a world not yet open is one muted line with its count and what opens it, so a new player sees a short list that lengthens as the day goes on. Sound, volume and haptics live under a chevron beside the speaker in the header rather than in the list; the counts of a garden’s rakings live in the tooltip of its par. (The first version showed every world’s pips, greyed, the three sound controls in the list, a “Sand” counter, the rule for every garden, and 290 words of help; on a phone the page ran to 2,400 pixels, and now to 1,400, with everything that was cut a hover or a tap away.) Accessibility. Every rule-bearing mark differs in shape, not only in color: hatching for bands, dashed rings for ripple stones, pale against dark pebbles, a dashed outline for cells the abbot points at, hatching for cut-off sand. The game is fully playable from a keyboard (arrows walk, Backspace steps back, Enter skips the reveal and goes on) and respects reduced-motion settings, as the prototype already does. A text description of each cell (terrain, mark, footprint, monk) with a coordinate-grid view would support non-visual play; that needs testing with screen-reader users rather than assuming. Prototype. The one-page prototype at the top of this page implements this interface without the camera tilt: worlds that open in turn (with a switch that opens them all, for testing), main paths and extras, the monk’s demonstrations and the first-drag hint, the monk waiting at the gate, drawing, rewinds and Restore, the turn counter against par, raking on letting go and stepping back after it, the refusals, the abbot’s three verdicts, the three assists, the gliding reveal, the vermilion and grey seals, the poem after each raking and the fade to the next garden, the scroll of poems earned, the daily garden, the practice garden, the sound (the synthesized pipe, drips, wind, birds, crickets, footsteps, scrape, bell and tok; the shakuhachi recording; haptics), and the sharing (rakings and progress as codes in the URL’s hash) (all in Presentation and Progression). It has the whole campaign of 148 gardens (see Progression), each with its poem; every par and raking count shown in it comes from the solvers, and its hint search agreed with the C solver on every one of 4,440 test routes (none ran out of budget). Where the game would teach wordlessly, the prototype states each new rule in a line of text. Presentation Sand as a heightfield. The original sketch suggested a falling-sand particle simulation. Raking does not make sand flow; it displaces it, so the right representation is a heightfield computed per pixel in a fragment shader: each cell’s groove type (straight, quarter-circle around the corner, or the rake’s start) gives the distance to each tine’s centerline, a smooth groove profile turns distance into height, and the height’s gradient is lit by a low sun whose angle follows the world’s time of day. Footprints are depressions stamped by decal; the reveal is a mask advancing along the stroke. This is cheap on any phone, resolution-independent, and gives the one thing that matters visually: ridges catching light. The prototype does a simpler version on the CPU, with about one height sample per screen pixel (32 to 144 per cell, so the sand is never visibly upscaled): footprints are pressed into the field as the player walks in, the abbot’s grooves are raked into it in advance, and the reveal writes the rake’s cross-section along the stroke frame by frame, the same engine as the practice garden (below). Gravel grain is procedural and never the same twice: two octaves of value noise in the height (so the ridges catch light unevenly), a per-pixel fleck and a slow mottle in the color, a few specks on top, and a fresh seed every time a garden is laid out. The rake is set down flush with whatever is behind its first cell (wall, stone, or the lane raked before), so no patch of sand is left between the start of the stroke and its neighbor. The grid is an abstraction, and the rendering admits it: a turn is drawn as an ideal quarter-circle stamp, which no rigid rake sweeps exactly; the picture is faithful to the stroke, not to rake physics. Practice garden. A garden with no rules is always open: drag anywhere to rake, curves and all; place or lift stones (the sand under a lifted stone stays unraked); smooth it flat and start again. Here the heightfield is written directly: the rake head, held perpendicular to its motion, sets the sand’s cross-section (a ridge between every pair of tines) along its path, and the same low sun lights it. It costs almost nothing, and it is the honest home for the sketch’s falling-sand toy: a sandbox for the hands, kept apart from the puzzles so it never weakens their rules (Astra’s suggestion). The prototype has one. Palette. White-grey granite gravel, sumi-e ink for stones and walls, moss green around the larger stones, and one accent: the vermilion of the novice’s ensō and the abbot’s seal (the two marks of a brush in the garden; the first prototype had a vermilion triangle in the gateway as well, redundant with the gap in the wall and with the monk waiting in it, so it is gone, and only a side gate’s exit keeps an outline triangle). No sepia or yellow cast; the gravel at Kyoto temples is white. The stones are ink-wash paintings, not 3D models; Ōkami shows how far sumi-e can go in motion. In the prototype each stone is drawn as one: a contour that is lumpy and a little rough, flatter at its base where it sits in the sand; a soft contact shadow cast down and to the right, where the sand’s light is from, as the blurred shadow of one path (so the two parts of a bent stone never darken each other), with a tighter, darker one at the base; an ink wash lighter toward the light; and a brushed edge, one band of varying weight, heavy where the ink pools on the shadow side and thinning to nothing where the brush ran dry on the lit side (in dark mode the edge is the pale rim light, so it is heavy on the lit side instead). This replaced the first prototype’s smooth capsules with a highlight, after GPT-6 Astra’s second review of it; a first attempt with per-segment strokes and stepped washes looked jagged and banded, which Gwern caught, so everything is now one smooth band or gradient. The novice is an ensō: one brushed circle, coming down rounded and heavy, thinning as it goes round, lifting before it closes, with two dry strands trailing past the lift, in vermilion, rubricated like an initial (the first prototype drew a filled black disc); he is drawn at the head of the walk, and, walking backwards, behind the rake; while he waits in the gateway he is its marker. Everything around the garden follows gwern.net’s design: black text on white, greys for whatever is secondary, serif text with sans-serif controls, square corners, hairline rules, no shadows, and dark mode as gwern.net has it (#161616 under #f1f1f1); the vermilion stays the one colour, on the novice’s ensō and the seal, and the abbot’s marks, the hint highlights and the faint route through the footprints, which the first prototype tinted blue and red, are ink. Sound. The garden has its own sounds and almost no music. A bamboo pipe drips into a stone basin now and then (every 7–23 s), and every minute or two the pipe tips onto its stone with a hollow knock and spills a few drops; a bonshō bell sounds once at the close of each world, its low partials beating and lasting; the seal lands with a dull wooden tok. Under them a wind moves, a bed of low filtered noise that wanders in level and colour every few seconds and swells into a gust every half-minute or so; by the local clock, small birds sing by day (phrases of two to six gliding notes, every 12–45 s, from 6 to 19) and crickets trill after dark (a high sine pulsed at about 28 Hz, from 20 to 5), so a page left open follows the evening. Each footprint is a step on gravel (a burst of grains over a soft thump), and the raking is heard: the rake’s scrape is grains of gravel under the tines, a looping crackle through a bandpass whose loudness, brightness and density follow the stroke’s speed frame by frame, with the rasp of tines dragged sideways in every bend and the monk’s backward step at every cell, lifting as the rake goes out through the gate. All of these are synthesized in the page with Web Audio (decaying sines, filtered noise and synthesized crackle; a short feedback delay for the basin’s echo), so they cost nothing to carry and never repeat exactly. After a world’s bell, and now and then otherwise (never within a quarter-hour of the last), a shakuhachi recording follows, faded in over 3 s and out over 4: the recordings are real and public domain, carried in the page as data: URLs with their credits and sources shown on the scroll of poems. The first is Shika no Tōne (鹿の遠音, “The Distant Cry of Deer”), the Kinko-school honkyoku for two shakuhachi answering each other, played by Araki Kodō III (1879–1935) on Victor 13029 between 1925 and 1935, from Wikimedia Commons (a Japanese phonogram whose neighbouring rights have expired; the de-noised derivative released as public domain by its maker), re-encoded as mono MP3 at 32 kbps (1.5 MB). Nothing sounds until the player has touched the page, as browsers require, and the first time it does a word beside the speaker says so. Silence is one tap away: a speaker button in the header (or M) mutes everything, Sound off in the list of gardens is the same switch, a slider beside it sets the volume, and all of it is remembered. The page also defers to what the browser and the device say. There is no CSS media query for a sound preference (Media Queries Level 5 has prefers-reduced-motion , -transparency , -contrast , -data and forced-colors , nothing for audio), so the signals that exist are used instead: where the browser has the Audio Session API (Safari 17), the page’s session is ambient, so a phone’s silent switch mutes it and it mixes with whatever the player was already listening to rather than stopping it; the system’s media keys (pause, stop) mute it while a recording plays, through the Media Session API, which also names the piece on the lock screen; and when the browser keeps the page’s audio from running after a gesture (a site set to block sound, or Firefox’s autoplay policy answering disallowed), the speaker shows it in red instead of pretending to play. Haptics tick once per cell (8 ms, where the device can; a switch beside the sound’s, shown only on such devices). A poem for each garden. The reward for raking a garden, at par or not, helped or not, is a poem: a moment after the reveal, a classical waka or haiku is laid over the raked sand in English on a white card with a hairline border (the first prototype blurred the grooves through a frosted plate), with the poet’s name set in by an indent (no dash, and no kanji: the first version put 原文 beside the name as the button for the original, and the poet’s name in kanji over the source, which Gwern found clutter), and a tap on “original” adds the Japanese and its source. About ten seconds later the garden and its poem fade together and the next garden fades in, so the campaign can be played as one slow, unbroken sequence; a tap on the poem goes on at once, a tap beside it (on the sand around the card, or the walls) puts the poem away and stays with the garden (a player’s note: interacting with the poem should go forth, dismissing it should go back; the first prototype had any tap stay), Next garden or Enter goes on at once, and nothing moves on while the page is hidden or scrolled away. The poem rewards finishing, not par, so it never punishes asking for help; the seal still records how the garden was raked. Every poem earned is kept on a scroll (Poems, beside the practice garden), in the order of the gardens with their Japanese and sources, with a count of what remains in each world, so the 76 extras have something to give besides seals; a garden’s name on the scroll leads back to it. The poems are real, and all written before 1900 (all but one by poets who died before it): 149 (one for each garden, and one for the practice garden) by 25 poets, from Bashō (34), Teika (23), Buson (20) and Rengetsu (13) down to single poems by Tsurayuki, Izumi Shikibu and Shiki. Each is paired by image or by structure. The first garden’s poem is Bashō sweeping a temple garden before he goes on (“Let me sweep the garden / before I go— / willow leaves fall in the temple.”). The garden before the first ripple stone gets Ryōkan’s new pond, where the frog jumps in and makes no sound; the stone arrives with Bashō’s old pond. The pair of gardens that introduces wave bands gets the two drafts of his Kiyotaki poem: waves “without a speck of dust” under the summer moon, and then, as he rewrote it on his deathbed, waves with green pine needles scattering into them. The last garden of the main path gets the one classical poem about temple sand hauled and tended by monks, Bashō’s at Kehi shrine, where the Yugyō abbots carried the sand in themselves: “The moon is clear / on the sand / the pilgrim monk carried.” The English versions are working translations, not quotations of published ones (see Credits). No koans. There is no narrative text, and no invented wisdom: apart from the rules, the only words are the poems, which are real. The novice’s progress is shown by his rake (bamboo, then the wide rake) and his robes (in the prototype he is an ensō, see Palette); the abbot appears only as a seated figure on the veranda and as his seal. Fake Zen aphorisms (or fake haiku) would cheapen exactly the thing the game is trying to be. Prior Art - Zen Puzzle Garden (Lexaloffle 2003): raking as a puzzle, with the ice-sliding rule (“rake all of the sand without turning corners or breaking raked lines”); 64 gardens across 4 seasons, an editor, and later pushable lanterns and leaves to collect in order. Its solvability is NP-complete (Houston et al 2012). - Zen Rock Garden (ZenRock 2021): “Just trace over all the sand panels with a single stroke!”, 300+ stages, with famous temple gardens as late motifs. This is the bare one-stroke rule, so Samon does not claim it; what is new here is the gate-anchored walk-in/rake-out reveal, proven-par scoring, the samon constraints, and the wide rake. - One-stroke and fill-the-board path puzzles: hitofude-gaki (一筆書き) apps; Flow Free (2012: “Pair all colors, and cover the entire board”); Zip (2025; Hamiltonian path through numbered waypoints, with walls); Nikoli genres like Numbrix/Hidato, whose given path fragments resemble the master’s grooves, and Masyu, whose pearls resemble the pebbles. - Line puzzles with deep progression: The Witness (constraints on a drawn line, taught wordlessly); LYNE (2014); Cosmic Express (2017; routing a single non-crossing track). - Optimization metrics: Opus Magnum and other Zachtronics games’ histograms. - Exits and pulling: Stephen’s Sausage Roll (the puzzle includes getting out), and Sokoban, of which raking is a kind of inverse: one pulls a rake rather than pushing blocks. The general problem is NP-complete, as Hamiltonian path is NP-complete even on grid graphs (Itai et al 1982), so no simple trick solves every garden; but garden-sized instances are trivial for a computer, which is what makes proven pars, proof-backed hints, and generated levels possible. Production Plan Rules before art. The order of work (Astra’s plan, adapted): (1) prove the rules in a plain prototype—drawing, undo, the rules, the hint search, a few gardens—before any art (done: the prototype attached to this page); (2) build a vertical slice of 12 gardens on real phones: the first five gardens, a ripple garden, a band garden, a groove garden, a pebble garden, a wide-rake garden, a side-gate garden, and a daily garden; one polished reveal; the histogram screen; (3) curate the campaign from generated candidates, several times more than needed, ordered by grade and revised by the mistakes players actually make (a first, uncurated pass is in the prototype); (4) only then the sensory layer: the shader, sound, haptics, low-power rendering, and accessibility passes. Pass or fail. The slice passes if players can say why a route failed after asking the abbot, carry an observation from one garden to the next, and choose to rake a finished garden again for the seal. If they brute-force, shrink the gardens; if they only trace forced moves, remove clues; if the reveal feels like a wait, shorten it; if they spend their time in the practice garden instead of the gardens, the puzzles are the problem, not the sand. Risks and Open Questions - The free worlds may feel trivial. Finishing an open garden is easy, and par is optional. Mitigation: keep world 1 short (9 gardens on its main path), give the seal real status, and introduce grooves (world 4) by the second hour (after 21 gardens). One early outside player raked at least 26 gardens on a phone, in the longer first campaign, and meant to play on. - The wide rake may be too hard. The grader says every wide-rake garden needs lookahead. Mitigation: start it at 5×5, use the hint, and order by grade; if playtesting is bad, move it to an optional post-game. - Genre saturation. One-stroke grids are a crowded genre. The differentiators are esthetic: the reveal, the score that is the beauty, the diegetic constraint vocabulary. - Scoring details. Decided: leaving through the gate counts as a turn, and U-turns braced against walls are not free (as they are in real raking). Both stay simple, and the turn count already rewards long lines; either would be a one-line change, but would change every par. - Parity frustration. Players who never discover parity will hit unwinnable endgames repeatedly. The two-tone tutorial gravel and the checkerboard assist are the mitigation; whether they suffice is an empirical question. - Esthetic curation. Turn density and “no open U-turns” are crude proxies for beauty. A learned layout scorer, validated against human ratings of rendered gardens, is the obvious next experiment. - Hints as a crutch. Proof-backed hints are strong: pressed repeatedly in a par-reachable route, the abbot sketches the whole answer. Decided after a first play session: helped rakings earn a grey seal (see Interface); Astra’s design kept hints free and unpenalized, and its review argued for free diagnosis; explanations of a stopped walk are now free, verdicts on a live route are not. Open: whether players will memorize helped answers to re-rake them for vermilion, and whether that matters. - Demonstrations may teach copying. Showing the calm raking of each introduction makes the rule’s look concrete, but the player then retraces it. If playtests show players learning the shape rather than the rule, demonstrate each rule on a separate, smaller garden, and leave the pair’s changed garden to the player. Methods How the numbers on this page were computed, and how they were checked. Everything is reproducible from the code, which is in the appendix. Exact solver. A depth-first search in C draws strokes from the gate inward (the reverse of raking; turns and U-turns are symmetric under reversal). Sand cells are bits in a 128-bit mask (gardens ≤128 cells). Each node prunes on: connectivity of the remaining sand through the head (a bitwise flood fill); degree (every unvisited cell needs two usable neighbors, except at most one, which must be the rake’s start); checkerboard parity of the remaining cells; required and forbidden directions per cell (encoding bands and grooves); pebbles (a pale pebble’s cell needs both neighbors on one axis usable, a dark pebble’s one on each axis); ring contiguity; and the U-turn rules. It enumerates the 1,510,446 rakings of the empty 7×7 garden in 1.9 seconds (33 million nodes). Validation. Its counts for empty gardens match OEIS A145157 (n = 3–7) and A145156 (5×4, 5×6). Against an independent brute-force enumerator (no pruning; rules checked only on complete paths) on ≈340,000 random small gardens (≈67,000 of them solvable) exercising every rule, it agreed on every count and every turn histogram; and on medium gardens it agreed with a separately-written CP-SAT model on counts, histograms, and minimum turns. Pebbles were checked the same way, on 6,000 further random small gardens against brute force and 108 medium ones against CP-SAT, with no disagreements; the grader’s pebble rules never derived a contradiction in a solvable garden, and every garden it solved, it solved to the right answer (711 random gardens, 79 of them with a unique raking). Astra’s review found two rules that the solvers and the prototype shared, and so could not catch by agreeing with each other: the bend just inside a side gate was drawn but not counted (in 13 of the 18 side-gate gardens then in the campaign), and the abbot’s finished sand counted as a pivot for the wide rake. Both were fixed in all three solvers and in the prototype, and checked again on 3,192 random small gardens against brute force (411 solvable, 132 of those with a side gate and 149 with finished sand) and on 120 medium ones against CP-SAT, with no disagreements. The recheck also found a bug in the CP-SAT model: a sand cell walled in by finished sand has no arcs, and a circuit constraint silently leaves such nodes out, so the model “solved” gardens that cannot be raked; every cell now needs exactly one incoming arc. Par. The CP-SAT model is a single circuit through all sand cells plus a dummy node (arcs from the dummy to any start cell, and from the gate cell back to the dummy), with straightness indicators per cell and direction, so turns = (cells − 1) − Σ straight; rings constrain the number of arcs entering the ring set to exactly 1; bands and grooves fix edges; a pebble fixes its cell’s straightness indicator and excludes it as the start; U-turns are three-literal clauses. It proves par for random 8×8 gardens in 0.2–1.8 seconds, and for the empty 9×9 garden in 4.4 seconds (2 CPU cores). Grader and generator. The grader is the tiered propagation solver described in Generating Gardens, in Python. Groove clues and pebbles are chosen greedily (each round, of ~24 random candidate cells from the intended raking, add the one leaving the fewest valid rakings), then pruned of redundant ones. Yields count every sampled layout, including those rejected on parity. Hint search. The prototype’s hints run a JavaScript depth-first search that uses the same rule code as the player’s moves and the C solver’s pruning (connectivity, dead ends, parity, pebbles), with a node budget. “Can this route still reach par?” needs no search: each garden ships with every one of its par rakings, and a route can reach par exactly when it is a prefix of one of them. Two things keep the search small. It remembers every position it has proved hopeless, where a position is the footprints, the head, and the last two moves (all the rules ever look at), so a dead end reached again by another route costs nothing. And because a completion of a route also completes every shorter part of it, whether a route can be finished switches from yes to no only once along it, so the nearest footprint to step back to is found by binary search, and is proven nearest by a single proof of impossibility at the footprint after it. On 4,440 routes over all 148 gardens (random legal walks, and prefixes of real rakings), every verdict it gave matched the C solver’s, and no search ran out of budget (an earlier campaign had one such route, in a 9×9 wide-rake garden, where the abbot said he could not tell yet); of the 2,011 rewinds offered from stuck routes, 1,989 were proven nearest and matched the C solver’s nearest footprint, and the other 22 (in three of the largest gardens) were footprints from which the garden could be finished, offered without the claim. Credits. Several ideas on this page come from a parallel design for the same sketch by GPT-6 Astra, “The Last Footprint” (unpublished): hints that judge the player’s own route, with proven rewinds; restorable rewinds and an explicit commit (now kept only for the daily garden); the practice garden; the “any turn” clue (the dark pebble; we added the pale one); the world template and per-garden records; the production plan and its pass/fail criteria; and the symmetry argument. Its five example gardens all check out under our solver. Astra later reviewed the prototype: it found four bugs (the grey seal washed out by Step back and Restore; the uncounted side-gate bends; finished sand as a pivot; an inconclusive hint that cost the seal), a dead end explained by the wrong rule (a side gate’s cell blamed on the wide rake), and the overclaim that the abbot’s taste is the wide rake’s physics, and it proposed the short main path and the contrasting pairs. Astra also chose the poems: a sourced waka or haiku, with a working translation, for each garden of the earlier 136-garden campaign. We carried them over to the 148 gardens (garden names follow layouts, so most pairings survived the reordering), paired the 13 new gardens (with 3 poems new to the selection), replaced two poems (Shiki’s) whose texts we could not confirm, checked every Japanese text and attribution against a page that prints it, and revised 22 of the translations. Two further passes (ours, then Astra’s review of it) replaced 25 pairings judged weak (a felicitation poem, filler, poems fastened to their gardens by a pun or by a shared noun) with better classical poems, among them Ryōkan’s new pond before Bashō’s old one, Bashō’s master carpenter for the abbot’s grooves, the Man’yōshū moon-boat for the star garden, Tamemasa’s unseen carp under trembling lotus leaves, and Saigyō’s snipe at the threshold; Astra also offered two original poems of its own, which we left out, so that every word the player reads is still a real poem. The texts, sources, pairings, credits and the log of replacements are in poems/samon-poems.json . License CC0. Everything here that is ours—Gwern’s contributions and all the LLM-written work: this document, the design, the solvers, experiments, tests and build scripts, the level generator and the 148 gardens and 366 daily gardens it produced, the prototype and its sounds (every one synthesized in the page), the figures, the English working translations of the poems, and the ideas merged from GPT-6 Astra’s parallel design—is released into the public domain under CC0 1.0. No attribution is required; none of it may be used to claim that Gwern or a contributor endorses a derived work. The poems are classical Japanese texts, all first published before 1900, and in the public domain everywhere; the pages each text was checked against are linked beside it in the game and in poems/samon-poems.json . The one recording, Shika no Tōne played by Araki Kodō III (Victor 13029, recorded between 1925 and 1935), is File:Shikanotoone_new.ogg on Wikimedia Commons, re-encoded as mono MP3. Commons records it as public domain: a Japanese phonogram whose neighbouring rights have expired under Article 101 of the Japanese Copyright Act, with the performer dead for over seventy years, and the de-noised derivative (Torsodog, 2008) released into the public domain by its maker. In the United States, sound recordings fixed before 1946 came under federal copyright by the Music Modernization Act for 100 years from publication, so a pressing from 1930 is not public domain there until 2031; Commons hosts the file as public domain in both Japan and the United States, and we rely on that. The page’s look follows gwern.net’s own stylesheets (colours and font stacks), which are CC0; no fonts are bundled, and no third-party code or images are included. The design document’s figures are our own renders; the puzzle counts quoted from the OEIS (A145157, A145156) are facts, not copies. See Also Appendix: Code and Data Download all 223 files as one .zip (1.8 MB; SHA-256 b60754f3fba988794a433d5d8c43cdf4a3160a07b05de806d7b1a664828b74d4 ). Start with src/zgsolve.c (exact search), src/zg.py (the rules, the solver driver and a brute-force checker), src/zcp.py (CP-SAT par proofs), src/grader.py (the deductive grader), and src/verify_doc.py , which re-derives every number in the document. The build and test commands are in the guide below. Not included: this page itself and the prototype's two pages, which standalone/build_standalone.py and proto/build.py write. Code behind the numbers and figures in the Samon design document (2026-10-03, revised 2026-10-04), and the playable prototype. The single-file edition of the document carries this whole tree in its appendix: the code is shown there, and every file is in the .zip it offers (all but the three generated pages, which the build scripts write). Build cd src && gcc -O3 -march=native -Wall -Wextra -o zgsolve zgsolve.c pip install ortools cairosvg playwright # CP-SAT optimiser; SVG→PNG for figures; browser test of the prototype playwright install chromium pip install beautifulsoup4 # (and pandoc) for the single-file edition # ffmpeg with libmp3lame, for sound/make_sound_bundle.py (the recordings; see sound/README.md) Paths: the scripts use absolute paths under /home/claude/zen/ (the directory this tree was developed in). To run from wherever this tree is unpacked, rewrite them once, from the top of the tree: grep --recursive --files-with-matches --exclude=README.md /home/claude/zen . | xargs sed --in-place "s|/home/claude/zen|$PWD|g" Files src/zgsolve.c : exact DFS solver (counts/enumerates rakings; turn & U-turn histograms; branch-and-bound min turns). Rules: one stroke ending at the gate; optional ripple rings, wave bands / ichimatsu, given grooves, pebbles (shape : S = straight through, B = turn), no-U-turn (nohairpin 1 ), wide rake (nohairpin 2 +endcells ), fixed rake start (two gates:end , andendside , the wall the side gate is in, so that a bend in its cell counts as a turn), the abbot’s finished sand (R : not walkable, and not a pivot for the wide rake).stream 1 prints every solution (used to export all par rakings).src/zg.py : garden model (text symbols:. sand,# stone,O ripple stone,h /v wave bands,= /| ichimatsu blocks;clues = grooves,marks = pebbles), solver driver, rule checker, brute-force enumerator.src/zcp.py : independent CP-SAT model (AddCircuit + dummy node) for par proofs and cross-checks.src/grader.py : tiered deductive solver used to grade difficulty.src/gen.py ,clues.py ,levels.py ,search*.py : layout sampling, greedy groove and pebble selection, level searches.src/exp_marks.py ,fig_pebbles.py : pebbles vs grooves on the same gardens (writesout/marks_vs_grooves.json ), and the pebbles figure.src/render.py ,final_figs.py : figure rendering (writesfig/final/*.png|svg ,out/facts.json ).src/final_tables.py ,exp_*.py : the tables and statistics quoted in the document.src/verify_doc.py : re-derives every number quoted in the document (C solver + CP-SAT); prints OK/BAD.src/test_crosscheck.py ,xc_stats.py ,xc_cpsat*.py : solver validation (brute force; CP-SAT; OEIS A145157/A145156).src/test_marks.py ,test_grader_marks.py : pebbles: C solver vs brute force vs CP-SAT; soundness of the grader’s pebble rules.src/garden_sampler.py : the garden-like stone sampler (odd-numbered, asymmetric groups of one- to three-cell stones; ripple stones, wave bands, closed rings, side gates, ichimatsu layouts).src/exp_sampler.py compares its yields with single-cell stones (writesout/sampler_yields.json ).src/campaign.py : generates the campaign (7 worlds, 148 gardens): generated gardens per world from the sampler and the pipeline’s filters, plus the hand-made and searched teaching gardens and the contrasting pairs; each world’s main path is the template (introduction, two applications, trap, combinations, calm garden, capstone; roles assigned by measurement; 72 gardens in all), and the rest of its gardens are optional extras (76); writesout/campaign.json andout/campaign_stats.json (per-spec candidates and the pairs are cached inout/campaign_cache/ ).src/pairs.py : searches for the contrasting pairs that introduce each rule (the same small garden before and after one change: a ripple stone, a band or the wide rake that breaks every par raking of the first; one groove that leaves one raking; pebbles in place of grooves; a side gate that leaves one of two wide-rake rakings).src/exp_continuity.py : how many of the par rakings rewarded in worlds 1–3 turn back in open sand (writesout/continuity.json ).src/test_side_pivot.py : rechecks the side gate’s turn counting and finished sand (no pivot) in the C solver against brute force (small gardens) and CP-SAT (medium); logs inout/test_side_pivot_seed*.log .src/hints.py : the abbot’s first observation for generated gardens: the provable fact that most changes the plan (free gardens: true of every par raking, and missed by the most near-misses; strict gardens: about the unique raking, and lowering the grade the most), with the cells the abbot points at; trap scores for the template’s ordering.src/export_proto.py : exports the campaign toout/proto_gardens.json , re-deriving each garden’s raking count (exact solver), par (CP-SAT), every par raking (for the in-page hints), and an intended raking (asserted valid and at par), with its first hint (authored, or fromhints.py ), its role, whether it is an extra, its pair, and whether the monk demonstrates it first; writes the hints’ measured effect toout/hint_stats.json .src/seed_garden_names.py ,out/garden_names.json : garden names follow layouts (a registry keyed by the garden’s rows, gates and clues), so a garden keeps its name, and its poem, when the campaign is regenerated or reordered.poems/samon-poems.json : the poems, one per garden and one for the practice garden: Japanese text, working translation (and its credit), poet, collection, the page each text was checked against, notes, the short source shown in the game, and why each poem goes with its garden.poems/astra-samon-poem-selection.json is GPT-6 Astra’s original selection for the 136-garden campaign (with its unused candidates);poems/pass2_replacements.py andpoems/pass3_astra_review.py are the second and third curation passes (25 swaps and 4 translation repairs, logged in the JSON’sabout.revisions );poems/originals-brainstorm.md holds original candidates for three gardens, not used.src/poems.py attaches the poems to the gardens.src/daily.py : a year of daily gardens (out/daily_gardens.json , 366; stats inout/daily_stats.json ): the campaign’s unused candidates plus fresh ones from its generators (cached inout/daily_cache/ ), none sharing a layout with the campaign, exported like the campaign’s gardens; the page picks the day’s by the local date.sound/ : the recordings carried in the page (tracks.json : credits and sources;make_sound_bundle.py : loudness measurement, static gain, mono MP3, writesout/sound_pieces.json ;README.md : how the sounds work and how a SingleFile/gwtar step can move the recordings out of the page).src/sound_pieces.py writes them into the page as data: URLs for both builds. The drips, pipe, wind, birds, crickets, footsteps, rake scrape, bell and seal are synthesized in the prototype itself.proto/samon.template.html ,proto/build.py : the prototype (one page; canvas; no dependencies, no requests).build.py embeds the gardens, their poems, the daily year and the recordings, and writessamon.html (a page body, for publishing as an artifact) andsamon.standalone.html (a complete HTML document, with the social-media tags, for self-hosting). Sharing lives in the template too: a raking is a code in the URL’s hash (#r= : the walk as a number in the mixed radix of its legal moves, 8–17 base64url characters;walkEncode /walkDecode ,rakingCode /readRaking ), shown but not credited to whoever opens it; all progress is another (#p= :progressCode /readProgress /importProgress , offered and merged); gardens are named in both by a 32-bit hash of their layout (layoutHash /byLayout ), never by index, so links survive a reordering and a changed garden is refused rather than mistaken;#daily opens today’s garden; the share text draws the raking in box-drawing characters (gridText ).proto/test/wrap.py ,proto/test/run.py : Playwright test; the first visit (the monk’s demonstration, which plays once, resets the garden, and is replayed by Watch or skipped by a tap; then the waiting monk and the first-drag hint); draws every garden’s intended raking with a real mouse, lets go, and checks that it is raked at par and sealed; walking back after raking, the keyboard’s beat, and a complete walk that may not end where it does; refusals (wide rake, pebbles); Restore; the abbot’s verdicts and rewinds; the grey seal (and that Step back and Restore keep it; that inconclusive answers and the reason a walk stopped are free); unlocking by the main path, extras (folded behind “+N more”, unfolding while one is open; locked worlds as one line), and Next; for every garden, that the bends drawn in the raked stroke = the turns counted = par; that finished sand is never a pivot; that a dead end is explained by the rule that actually fails; assists; the practice garden; the poems (every garden has one; it follows any raking, helped or off par; the next garden fades in after it; a tap stays; ‘original’ shows the Japanese; none during a demonstration); the scroll of poems earned; the daily garden (picked by date, Rake commits, the record and streak survive a reload, the next day is fresh); drag intent (a hand drifting off the row still draws the row; the grip survives leaving the field; arcs, rounded corners, reversals and wobbles); sound (no context before a gesture; each synthesized sound renders audibly; Sound off persists; the recording is found, credited with its source, put down to follow a world’s bell, heard, and let go; the scrape, footsteps, birds, crickets and wind render; the scrape runs with the raking; day and night by the clock; volume and haptics settings remembered, in the panel under the speaker’s chevron; the speaker in the header and M mute; a browser that will not play is shown; a tap on the poem goes on and a tap beside it stays); sharing (every intended raking round-trips through its code; an illegal walk has none; the share text’s heading, drawing and link; a#r= link shows the raking with its seal, credited to nobody, and a tap or Rake it yourself gives the viewer the garden fresh with nothing to Restore; the grey seal travels; a daily garden’s raking from another day sends the viewer on to today’s; Share this raking copies text and link, and the link shows it;#p= is offered, then merged with better-state-wins, carrying the day’s record and an unfinished walk with its help; the daily share carries shape and score but no stroke;#daily ; bad codes are refused with a word; every garden’s layout hash is distinct; a tampered hash is refused; links made before a reordering of the campaign still open, and credit, the right garden; every control has a tooltip); screenshots in light and dark. Every garden’s intended raking is stored inout/proto_gardens.json , so nothing is solved at test time; the minutes go on real mouse drags and real-time reveals.SAMON_QUICK=1 (about 3 minutes) drags three gardens instead of 148 and samples the abbot’s explanations on 30 random routes per garden instead of 400.proto/test/fuzz.py : a gesture fuzzer: random drags, taps, keys, buttons, pips, pills and reloads against the page’s invariants (a legal walk, consistent mode and controls, bracketed status, bounded sound timers, progress that parses and a draft that survives a reload; no errors).python3 proto/test/fuzz.py [seconds] [seed] . It found two bugs (a draft lost to a page closed within the save’s debounce; the page returning to the first unraked garden instead of the one open), so it stays.proto/test/thumbnail.py : renders the social-media thumbnail (deliver/2026-10-05-gwern-samon-thumbnail.png , 1200×630: a garden raked at par beside the title);thumbnail.py pick renders candidates.proto/test/gt_check.py : checks the page’s hint search (can still reach par / can still be finished / nearest completable footprint) against the C solver on random routes; writesout/hint_check.json (gt_check.py 15 : 30 routes per garden, the 4,440 routes quoted in the document).standalone/build_standalone.py : builds the single-file edition,standalone/2026-10-03-gwern-samon.html : the design document converted fromsamon.md by Pandoc (figures fromfig/final/ embedded as SVG data URIs; Wikipedia shorthand resolved) with the playable prototype in the same page, an appendix holding this tree (code shown; every file in an embedded .zip, also written tostandalone/2026-10-03-gwern-samon-code.zip ), and no external resources.standalone/test_standalone.py checks that the page makes no requests, that figures, contents and collapsed sections work, that the appendix’s files and its .zip match this tree and the download delivers the .zip, and runs the prototype tests (it is generated fromproto/test/run.py bystandalone/make_test_standalone.py ).samon.md : the design document (Gwerndown).fig/final/ : its figures (PNG and SVG).out/ : saved levels, picks, and experiment outputs. The guide and the document README.md # Samon: solver, experiment, and prototype code Code behind the numbers and figures in the _Samon_ design document (2026-10-03, revised 2026-10-04), and the playable prototype. The single-file edition of the document carries this whole tree in its appendix: the code is shown there, and every file is in the .zip it offers (all but the three generated pages, which the build scripts write). ## Build cd src && gcc -O3 -march=native -Wall -Wextra -o zgsolve zgsolve.c pip install ortools cairosvg playwright # CP-SAT optimiser; SVG→PNG for figures; browser test of the prototype playwright install chromium pip install beautifulsoup4 # (and pandoc) for the single-file edition # ffmpeg with libmp3lame, for sound/make_sound_bundle.py (the recordings; see sound/README.md) Paths: the scripts use absolute paths under `/home/claude/zen/` (the directory this tree was developed in). To run from wherever this tree is unpacked, rewrite them once, from the top of the tree: grep --recursive --files-with-matches --exclude=README.md /home/claude/zen . | xargs sed --in-place "s|/home/claude/zen|$PWD|g" ## Files - `src/zgsolve.c`: exact DFS solver (counts/enumerates rakings; turn & U-turn histograms; branch-and-bound min turns). Rules: one stroke ending at the gate; optional ripple rings, wave bands / ichimatsu, given grooves, pebbles (`shape`: S = straight through, B = turn), no-U-turn (`nohairpin 1`), wide rake (`nohairpin 2` + `endcells`), fixed rake start (two gates: `end`, and `endside`, the wall the side gate is in, so that a bend in its cell counts as a turn), the abbot's finished sand (`R`: not walkable, and not a pivot for the wide rake). `stream 1` prints every solution (used to export all par rakings). - `src/zg.py`: garden model (text symbols: `.` sand, `#` stone, `O` ripple stone, `h`/`v` wave bands, `=`/`|` ichimatsu blocks; `clues` = grooves, `marks` = pebbles), solver driver, rule checker, brute-force enumerator. - `src/zcp.py`: independent CP-SAT model (AddCircuit + dummy node) for par proofs and cross-checks. - `src/grader.py`: tiered deductive solver used to grade difficulty. - `src/gen.py`, `clues.py`, `levels.py`, `search*.py`: layout sampling, greedy groove and pebble selection, level searches. - `src/exp_marks.py`, `fig_pebbles.py`: pebbles vs grooves on the same gardens (writes `out/marks_vs_grooves.json`), and the pebbles figure. - `src/render.py`, `final_figs.py`: figure rendering (writes `fig/final/*.png|svg`, `out/facts.json`). - `src/final_tables.py`, `exp_*.py`: the tables and statistics quoted in the document. - `src/verify_doc.py`: re-derives every number quoted in the document (C solver + CP-SAT); prints OK/BAD. - `src/test_crosscheck.py`, `xc_stats.py`, `xc_cpsat*.py`: solver validation (brute force; CP-SAT; OEIS A145157/A145156). - `src/test_marks.py`, `test_grader_marks.py`: pebbles: C solver vs brute force vs CP-SAT; soundness of the grader's pebble rules. - `src/garden_sampler.py`: the garden-like stone sampler (odd-numbered, asymmetric groups of one- to three-cell stones; ripple stones, wave bands, closed rings, side gates, ichimatsu layouts). `src/exp_sampler.py` compares its yields with single-cell stones (writes `out/sampler_yields.json`). - `src/campaign.py`: generates the campaign (7 worlds, 148 gardens): generated gardens per world from the sampler and the pipeline's filters, plus the hand-made and searched teaching gardens and the contrasting pairs; each world's main path is the template (introduction, two applications, trap, combinations, calm garden, capstone; roles assigned by measurement; 72 gardens in all), and the rest of its gardens are optional extras (76); writes `out/campaign.json` and `out/campaign_stats.json` (per-spec candidates and the pairs are cached in `out/campaign_cache/`). - `src/pairs.py`: searches for the contrasting pairs that introduce each rule (the same small garden before and after one change: a ripple stone, a band or the wide rake that breaks every par raking of the first; one groove that leaves one raking; pebbles in place of grooves; a side gate that leaves one of two wide-rake rakings). - `src/exp_continuity.py`: how many of the par rakings rewarded in worlds 1--3 turn back in open sand (writes `out/continuity.json`). - `src/test_side_pivot.py`: rechecks the side gate's turn counting and finished sand (no pivot) in the C solver against brute force (small gardens) and CP-SAT (medium); logs in `out/test_side_pivot_seed*.log`. - `src/hints.py`: the abbot's first observation for generated gardens: the provable fact that most changes the plan (free gardens: true of every par raking, and missed by the most near-misses; strict gardens: about the unique raking, and lowering the grade the most), with the cells the abbot points at; trap scores for the template's ordering. - `src/export_proto.py`: exports the campaign to `out/proto_gardens.json`, re-deriving each garden's raking count (exact solver), par (CP-SAT), every par raking (for the in-page hints), and an intended raking (asserted valid and at par), with its first hint (authored, or from `hints.py`), its role, whether it is an extra, its pair, and whether the monk demonstrates it first; writes the hints' measured effect to `out/hint_stats.json`. - `src/seed_garden_names.py`, `out/garden_names.json`: garden names follow layouts (a registry keyed by the garden's rows, gates and clues), so a garden keeps its name, and its poem, when the campaign is regenerated or reordered. - `poems/samon-poems.json`: the poems, one per garden and one for the practice garden: Japanese text, working translation (and its credit), poet, collection, the page each text was checked against, notes, the short source shown in the game, and why each poem goes with its garden. `poems/astra-samon-poem-selection.json` is GPT-6 Astra's original selection for the 136-garden campaign (with its unused candidates); `poems/pass2_replacements.py` and `poems/pass3_astra_review.py` are the second and third curation passes (25 swaps and 4 translation repairs, logged in the JSON's `about.revisions`); `poems/originals-brainstorm.md` holds original candidates for three gardens, not used. `src/poems.py` attaches the poems to the gardens. - `src/daily.py`: a year of daily gardens (`out/daily_gardens.json`, 366; stats in `out/daily_stats.json`): the campaign's unused candidates plus fresh ones from its generators (cached in `out/daily_cache/`), none sharing a layout with the campaign, exported like the campaign's gardens; the page picks the day's by the local date. - `sound/`: the recordings carried in the page (`tracks.json`: credits and sources; `make_sound_bundle.py`: loudness measurement, static gain, mono MP3, writes `out/sound_pieces.json`; `README.md`: how the sounds work and how a SingleFile/gwtar step can move the recordings out of the page). `src/sound_pieces.py` writes them into the page as data: URLs for both builds. The drips, pipe, wind, birds, crickets, footsteps, rake scrape, bell and seal are synthesized in the prototype itself. - `proto/samon.template.html`, `proto/build.py`: the prototype (one page; canvas; no dependencies, no requests). `build.py` embeds the gardens, their poems, the daily year and the recordings, and writes `samon.html` (a page body, for publishing as an artifact) and `samon.standalone.html` (a complete HTML document, with the social-media `` tags, for self-hosting). Sharing lives in the template too: a raking is a code in the URL's hash (`#r=`: the walk as a number in the mixed radix of its legal moves, 8–17 base64url characters; `walkEncode`/`walkDecode`, `rakingCode`/`readRaking`), shown but not credited to whoever opens it; all progress is another (`#p=`: `progressCode`/`readProgress`/`importProgress`, offered and merged); gardens are named in both by a 32-bit hash of their layout (`layoutHash`/`byLayout`), never by index, so links survive a reordering and a changed garden is refused rather than mistaken; `#daily` opens today's garden; the share text draws the raking in box-drawing characters (`gridText`). - `proto/test/wrap.py`, `proto/test/run.py`: Playwright test; the first visit (the monk's demonstration, which plays once, resets the garden, and is replayed by Watch or skipped by a tap; then the waiting monk and the first-drag hint); draws every garden's intended raking with a real mouse, lets go, and checks that it is raked at par and sealed; walking back after raking, the keyboard's beat, and a complete walk that may not end where it does; refusals (wide rake, pebbles); Restore; the abbot's verdicts and rewinds; the grey seal (and that Step back and Restore keep it; that inconclusive answers and the reason a walk stopped are free); unlocking by the main path, extras (folded behind "+N more", unfolding while one is open; locked worlds as one line), and Next; for every garden, that the bends drawn in the raked stroke = the turns counted = par; that finished sand is never a pivot; that a dead end is explained by the rule that actually fails; assists; the practice garden; the poems (every garden has one; it follows any raking, helped or off par; the next garden fades in after it; a tap stays; 'original' shows the Japanese; none during a demonstration); the scroll of poems earned; the daily garden (picked by date, Rake commits, the record and streak survive a reload, the next day is fresh); drag intent (a hand drifting off the row still draws the row; the grip survives leaving the field; arcs, rounded corners, reversals and wobbles); sound (no context before a gesture; each synthesized sound renders audibly; Sound off persists; the recording is found, credited with its source, put down to follow a world's bell, heard, and let go; the scrape, footsteps, birds, crickets and wind render; the scrape runs with the raking; day and night by the clock; volume and haptics settings remembered, in the panel under the speaker's chevron; the speaker in the header and M mute; a browser that will not play is shown; a tap on the poem goes on and a tap beside it stays); sharing (every intended raking round-trips through its code; an illegal walk has none; the share text's heading, drawing and link; a `#r=` link shows the raking with its seal, credited to nobody, and a tap or Rake it yourself gives the viewer the garden fresh with nothing to Restore; the grey seal travels; a daily garden's raking from another day sends the viewer on to today's; Share this raking copies text and link, and the link shows it; `#p=` is offered, then merged with better-state-wins, carrying the day's record and an unfinished walk with its help; the daily share carries shape and score but no stroke; `#daily`; bad codes are refused with a word; every garden's layout hash is distinct; a tampered hash is refused; links made before a reordering of the campaign still open, and credit, the right garden; every control has a tooltip); screenshots in light and dark. Every garden's intended raking is stored in `out/proto_gardens.json`, so nothing is solved at test time; the minutes go on real mouse drags and real-time reveals. `SAMON_QUICK=1` (about 3 minutes) drags three gardens instead of 148 and samples the abbot's explanations on 30 random routes per garden instead of 400. - `proto/test/fuzz.py`: a gesture fuzzer: random drags, taps, keys, buttons, pips, pills and reloads against the page's invariants (a legal walk, consistent mode and controls, bracketed status, bounded sound timers, progress that parses and a draft that survives a reload; no errors). `python3 proto/test/fuzz.py [seconds] [seed]`. It found two bugs (a draft lost to a page closed within the save's debounce; the page returning to the first unraked garden instead of the one open), so it stays. - `proto/test/thumbnail.py`: renders the social-media thumbnail (`deliver/2026-10-05-gwern-samon-thumbnail.png`, 1200×630: a garden raked at par beside the title); `thumbnail.py pick` renders candidates. - `proto/test/gt_check.py`: checks the page's hint search (can still reach par / can still be finished / nearest completable footprint) against the C solver on random routes; writes `out/hint_check.json` (`gt_check.py 15`: 30 routes per garden, the 4,440 routes quoted in the document). - `standalone/build_standalone.py`: builds the single-file edition, `standalone/2026-10-03-gwern-samon.html`: the design document converted from `samon.md` by Pandoc (figures from `fig/final/` embedded as SVG data URIs; Wikipedia shorthand resolved) with the playable prototype in the same page, an appendix holding this tree (code shown; every file in an embedded .zip, also written to `standalone/2026-10-03-gwern-samon-code.zip`), and no external resources. `standalone/test_standalone.py` checks that the page makes no requests, that figures, contents and collapsed sections work, that the appendix's files and its .zip match this tree and the download delivers the .zip, and runs the prototype tests (it is generated from `proto/test/run.py` by `standalone/make_test_standalone.py`). - `samon.md`: the design document (Gwerndown). `fig/final/`: its figures (PNG and SVG). - `out/`: saved levels, picks, and experiment outputs. samon.md --- title: "_Samon_: Designing a Zen Garden Raking Puzzle" author: Gwern, Claude-5.5-opus description: "Full design for a one-stroke sand-raking puzzle game: a novice monk walks into a dry garden and must rake his way back out to the gate. Solver experiments show the bare rule is loose (1.5 million rakings of a 7×7 garden), but scoring by turns recovers the traditional ripple and wave patterns, and a 'wide rake' rule forbidding U-turns in open sand yields unique, garden-like solutions. Covers rules, mechanics, progression, level generation & grading, interface (including hints that are proofs about the player's own route), presentation, a production plan, and prior art, with a playable prototype." thumbnail: /doc/design/2026-10-05-gwern-samon-thumbnail.png thumbnail-text: "The prototype's garden 'Ripples' raked at par: the stroke's grooves ring a stone in square waves, with the abbot's vermilion seal in the corner; beside it the title Samon 砂紋 and the rule in one sentence." created: 2023-02-13 modified: 2026-10-05 status: in progress confidence: likely importance: 3 css-extension: dropcaps-kanzlei ... Raking a Zen dry garden is a chore with the shape of a puzzle: a novice who walks into the sand leaves footprints, and must rake his way back out to the gate without crossing his own lines or raking anything twice. We design _Samon_ ("sand patterns"), a puzzle game built on that rule: the player draws one stroke from the gate covering every cell of sand; the monk walks it in, sets down the rake, and walks backwards out, revealing the pattern. We formalize gardens as grid graphs and rakings as Hamiltonian paths ending at the gate, show that "walk in, rake out" and the simpler "mirror raking" are exactly equivalent (so the interface can be "draw from the gate"), and test candidate mechanics on thousands of random gardens with an exact solver, a constraint-programming optimizer, and a deductive difficulty grader. The bare rule is loose: an empty 7×7 garden with a corner gate has 1,510,446 valid rakings, and random stone layouts are mostly either unsolvable (often by checkerboard parity) or wide open. Scoring by turns fixes the slack without new rules: in that 7×7 garden only 66 rakings reach the minimum ("par") of 12 turns, and par rakings in lightly-stoned gardens tend to be the traditional motifs of parallel lines and rings around stones. A single physical rule, the **wide rake** (no U-turn in open sand; set the rake down against a stone or wall), collapses gardens to a handful of rakings, often exactly one, and in about half of the calm-looking unique gardens that one is also a par raking of the unrestricted garden: in one 8×8 garden it picks out the only U-turn-free raking among 12 par rakings of 6,310,226. Ripple stones and wave bands, the obvious "pattern" constraints, add texture but not tightness; given grooves pin down unique answers with ~5--8 clues, and pebbles that say only "straight here" or "turn here" need more clues (a median of 11 against 7) but make nearly every garden a lookahead puzzle. The resulting design has 7 worlds, each adding one rule (shown by a before-and-after pair of small gardens, the second raked once by the monk as a demonstration); free gardens scored against a proven par with [_Opus Magnum_](!W)-style histograms; strict gardens with unique, graceful answers; hints that are proofs about the player's own route (it can still reach par; it can still be finished; or it cannot, and here is the nearest footprint to step back to); a daily garden that can be raked only once; a classical poem as the reward for every raked garden, after which the next garden fades in; a heightfield sand renderer and a free-raking practice garden; a level pipeline whose yields and difficulty distribution we measure; and a production plan with pass/fail criteria. A playable prototype implements most of it, with the whole campaign of 148 gardens and their poems. Zen sand gardens are often intended for meditation, whether to look at or while maintaining it by raking the sand into the prescribed patterns which are formed by the tines of the rake yielding parallel lines (like `|||`). Such a chore might be tedious, but there is also a great deal of satisfaction to restoring an esthetic order from chaos, and raking sand seems like one of those things, like popping bubblewrap, which can be oddly satisfying---I recall the lifeguards at my summer camp seeming to rather enjoy the task of raking the beach in front of their office every day and not allowing any campers to do so. Puzzle-solving is also often described as being 'meditative' and 'oddly satisfying', even when it looks a lot like a tedious chore. So for those of us who do not live at a Zen temple, can we make a Zen sand garden *puzzle*? This page turns the original 2023 sketch into a full design. Where the sketch speculated (would "mirror raking" be too restrictive? could a neural net rank solutions by beauty? how should difficulty be raised?), we answer with a solver and measurements; where it left gaps (what exactly makes a garden hard, what the worlds are, how levels are generated, what the player touches), we fill them. The title is _Samon_ (砂紋, "sand crest"), the term for the patterns themselves.[^samon] [^samon]: [JAANUS](https://projects.mcah.columbia.edu/jaanus/node/1584 "'samon 砂紋', JAANUS"): "The technique of making a pattern or design in sand or gravel... Typically in such gardens, the gravel is raked into a series of ridges or ripples which suggest waves in an ocean, but may well be pure abstraction." Garden associations list ~7 standard patterns, including _sazanami_ (continuous ripples), _mizumon_ (concentric rings), _ryūsui_ (flowing water) and _ichimatsu_ (checkerboard) ([NAJGA 2023](https://najga.org/7-common-raking-patterns-in-japanese-dry-landscape-gardens/ "'7 Common Raking Patterns In Japanese Dry Landscape Gardens', NAJGA 2023")). # The Garden [Pieces.]{.margin-note} A garden is a walled rectangle of white gravel---in Kyoto, typically _Shirakawa-suna_, weathered granite from the Kitashirakawa area ([Nabunken](https://www.nabunken.go.jp/org/bunka/jgd/pages/ShirakawaSand.html "'Shirakawa sand', Nara National Research Institute for Cultural Properties"))---divided into square cells, with stones as obstacles. One cell on the veranda side is the **gate**: the gap in front of the _kutsunugi-ishi_, the flat stone below the veranda where one removes one's shoes ([JAANUS](https://projects.mcah.columbia.edu/jaanus/node/2714 "'kutsunugi-ishi 沓脱石', JAANUS")). The player is an _[unsui](!W)_, a novice monk, and the garden is viewed, as at [Ryōan-ji](!W), from the abbot's veranda: the bottom edge of the screen. [The rule.]{.margin-note} The novice steps off the shoe-stone, walks into the sand, puts down his rake, and walks *backwards* to the gate, dragging the rake over his own footprints. The garden is finished when every cell of sand has been raked exactly once, the lines are unbroken, and he is back on the shoe-stone. That is the whole rule: one stroke, every cell once, no crossing, ending at the gate. ![*Anatomy of a raking.*
(*1*) A 6×6 garden with 2 stones; the gate is the gap in the bottom wall.
(*2*) The player draws the walk in from the gate; the monk leaves footprints.
(*3*) At the end of the walk he sets down the rake (red dot) and walks backwards, the rake erasing his footprints.
(*4*) The finished garden: 10 turns, which is par; 2 of this garden's 1,023 valid rakings reach it.](/doc/design/2026-10-03-gwern-samon-anatomy.png){.width-full} [Formally: Hamiltonian paths.]{.margin-note} Formally, the sand cells form a grid graph, and a valid raking is a [Hamiltonian path](!W) of that graph which ends at the gate cell. The rake lines are the path's shape: a cell traversed straight gets parallel grooves; a cell where the path turns gets concentric quarter-circles; the cell where the rake was set down gets the groove's start. Every claim below about counts, pars, and difficulty comes from an exact solver for this formulation (see [Methods](#methods)). [Walking in is free.]{.margin-note} The original sketch distinguished the forward walk (leaving footprints) from the backward raking, and proposed "mirror raking"---the monk retraces his walk exactly---as a possibly-too-restrictive simplification. It is not a restriction at all. Any walk in, followed by a raking that covers every cell exactly once and ends at the gate, erases every footprint, because every cell gets raked; so the walk in is irrelevant except for where it ends, and the set of finished gardens is exactly the set of Hamiltonian paths ending at the gate. Conversely, every such path is achieved by walking it in and raking it out.[^mirror-proof] So mirror raking loses nothing, and the interface can be the simplest possible: *draw a path from the gate*; that path is the walk in; the raking is its reverse. [^mirror-proof]: Proof sketch: let the walk in be any walk _W_ from the gate to cell _P_, and the raking _R_ any path from _P_ to the gate that visits every sand cell once. Every cell _W_ touched is on _R_, so all footprints are raked over; the finished pattern is _R_ alone. Hence {finished gardens} = {Hamiltonian paths ending at the gate} regardless of _W_, and choosing _W_ = reverse(_R_) shows every such path is reachable by mirror raking. The one physical constraint---never step on raked sand---holds automatically, since walking backwards the monk only steps onto cells the rake has not yet reached. This matters for design because the story stays intact (the reveal is the monk raking himself out of the garden) while the player's task is a single, legible gesture. # The Bare Rule Is Loose [Too many rakings.]{.margin-note} The one-stroke rule alone is a weak constraint in open sand. For an empty garden with a corner gate, the number of valid rakings is the number of Hamiltonian paths from a corner, [OEIS A145157](https://oeis.org/A145157 "'Number of Greek-key tours on an n X n board; i.e., self-avoiding walks on n X n grid starting in top left corner', OEIS"), which grows explosively: 824 for 5×5, 1,510,446 for 7×7, 54,986,690,944 for 9×9. (Our solver reproduces A145157 and the 5×_n_ sequence [A145156](https://oeis.org/A145156) exactly, which is a useful correctness check.) Anyone can rake an open garden: walk up and down in rows. | Garden | Valid rakings | Par (min. turns) | Rakings at par | Median turns | Max turns | Wide-rake rakings | |:------:|--------------:|:----------------:|---------------:|:------------:|:---------:|:-----------------:| | 3×3 | 8 | 4 | 2 | 5 | 6 | 2 | | 4×4 | 52 | 6 | 4 | 8 | 13 | 4 | | 5×5 | 824 | 8 | 10 | 13 | 20 | 4 | | 6×6 | 22,144 | 10 | 24 | 20 | 31 | 6 | | 7×7 | 1,510,446 | 12 | 66 | 27 | 42 | 6 | | 6×8 | 899,432 | 11 | 66 | 26 | 42 | 8 | Table: Empty gardens, gate in the bottom-left corner of the south (veranda) wall. "Turns" counts every cell where the stroke changes direction, including leaving through the gate. The last column anticipates the [wide rake](#the-wide-rake). [Parity.]{.margin-note} Stones make the rule bite, but mostly by making gardens *impossible*. Color the cells like a checkerboard: every step of the stroke changes color, so a stroke over _n_ cells alternates colors, and if one color has more cells, the stroke must begin and end on it. A 5×5 garden has 13 dark and 12 light cells, so its gate must be on a dark cell; move the gate one cell along the wall and there is no raking at all. Of random 7×7 gardens with a gate somewhere on the veranda wall, 57% pass this parity test with no stones and only 31% with 6 stones. ![*Checkerboard parity decides solvability before anything else.*
A 5×5 garden has 13 dark and 12 light cells, so the stroke must start and end on dark cells.
(*Left*) Gate on a dark corner: 824 valid rakings (one at par, 8 turns, shown).
(*Right*) Gate one cell over, on a light cell: no raking exists.](/doc/design/2026-10-03-gwern-samon-parity.png) [Random stones: unsolvable or open.]{.margin-note} Past parity, stones create dead ends (a cell with one free neighbor must be where the rake is set down---at most one such cell may exist) and corridors (a cell with two free neighbors must use both). These are the classic deductions of one-stroke puzzles, and they are real, but random stone placement produces them in the wrong proportion: gardens are either unsolvable or still have thousands of rakings. In 300 random 7×7 gardens per row, *none* with 0--6 random stones had a unique raking: | Stones | Parity OK | One stroke: solvable | One stroke: unique | One stroke: median rakings | No U-turns at all: solvable | Wide rake: solvable | Wide rake: unique | Wide rake: median rakings | |:------:|:---------:|:--------------------:|:------------------:|:--------------------------:|:---------------------------:|:-------------------:|:-----------------:|:-------------------------:| | 0 | 57% | 57% | 0% | ≥100,000 | 0% | 57% | 0% | 6 | | 2 | 43% | 42% | 0% | 28,326 | 0% | 18% | 2.7% | 2 | | 4 | 34% | 20% | 0% | 610 | 0% | 2.7% | 0.3% | 3 | | 6 | 31% | 8% | 0% | 96 | 0% | 0.3% | 0% | 4 | Table: 300 random 7×7 gardens per row, random 1×1 stones, gate at a random cell of the south wall. Medians are over solvable gardens (counts capped at 100,000). The wide-rake columns are explained [below](#the-wide-rake). The bare rule therefore cannot carry a game by itself: completion is trivial in open sand, and the obstacles that make it non-trivial mostly make it impossible. Two things fix this: a *score* that makes the slack matter, and *rules* that remove it. Pleasingly, both come from what makes a raked garden beautiful. # Calm: Turns As Score [The abbot counts turns.]{.margin-note} The commonest _samon_ are long parallel lines, interrupted where they ring a stone. A raking that zigzags looks agitated. So the game's single esthetic metric is the number of **turns**: every cell where the stroke changes direction. Fewer turns is calmer. Each garden has a **par**, the minimum number of turns over all valid rakings, *proven* by a constraint solver (in a few seconds at most, for gardens up to 9×9). Any valid raking finishes the garden; a raking at par earns the abbot's vermilion seal. ![*The same 7×8 garden raked three ways.*
(*Left*) Par, 17 turns: 6 of its 103,812 valid rakings achieve it.
(*Middle*) A median raking, 31 turns.
(*Right*) The most agitated possible raking, 48 turns (5 rakings).](/doc/design/2026-10-03-gwern-samon-calm-agitated.png){.width-full} [Par is a needle.]{.margin-note} Turns make the looseness useful. The distribution of turns over all valid rakings is a broad hump, and par sits in the far tail: in the empty 7×7 garden, 66 of 1,510,446 rakings (0.004%) reach par, and the median raking has more than twice as many turns. Finishing a garden is easy; finishing it *calmly* is a real optimization puzzle, of the kind where intuition ("make long lines; circle the stones; use the walls") gets close and the last two turns take thought. ![*Turn counts of all 103,812 valid rakings of the garden above.* Par (17 turns) is achieved by 6 rakings; the median raking takes 31.](/doc/design/2026-10-03-gwern-samon-histogram.png) [Histograms.]{.margin-note} This is the structure of [Zachtronics](!W) optimization games, where after solving, the player's solution is ranked against every other player's on a few metrics ([_Opus Magnum_](!W) compares cycles, cost, and area). _Samon_ shows one histogram per garden after completion: the player's turn count against everyone's, with par marked---a picture very like the one above, filled in by real players. Because par is *proven*, the seal is never a trap (no "par" that nobody can reach) and never a lie (no raking can beat it). [Par tends to look traditional.]{.margin-note} The surprise is that minimizing a single number recovers the traditional motifs. In the open and lightly-stoned gardens we inspected, par rakings were nested U-bends and spirals: long parallel lines across the garden (_sazanami_), and concentric rings around stones (_mizumon_), without either being specified. The rings appear because circling a stone is the cheapest way to use the cells next to it; the parallel lines appear because every turn costs. This is not a law, though: with crowded or awkwardly-placed stones, par rakings degenerate into meanders full of tight U-turns in open sand. Among 60 random solvable gardens (6×7--7×8, 1--3 stones), only 40% admitted *any* raking free of open-sand U-turns, and only 20% had a par raking that was free of them; so "par is beautiful" is a property for the level curator to select for (and we do: see [Generating Gardens](#generating-gardens)), not something the rule guarantees. [Why not a neural esthetic score?]{.margin-note} The sketch proposed ranking solutions with a learned esthetic model, such as the CLIP-based predictor behind [LAION-Aesthetics](https://laion.ai/blog/laion-aesthetics/). For *scoring the player*, a hand-countable metric is better: the player can see it, count it, and deliberately optimize it, and a proven optimum exists; an opaque neural score can be none of these, and would invite adversarial raking. The learned model is better spent upstream, ranking *layouts* (which stone arrangements look like real gardens, which par rakings are prettiest when several tie), where opacity costs nothing. # Mechanics [One rule per world.]{.margin-note} The standing criticism of [_Zen Puzzle Garden_](https://www.lexaloffle.com/zen.php "'Zen Puzzle Garden', Lexaloffle 2003"), the closest ancestor, was monotony: "there's still a persisting feeling that, even by the final few stages, _Zen Puzzle Garden_ hasn't developed one step beyond what the tutorial already demonstrated" ([Wilson 2011](https://www.pocketgamer.com/zen-puzzle-garden/review/ "'Zen Puzzle Garden Review', Wilson 2011")). So each world adds one rule, and each rule must earn its place by changing *how the player thinks*, not just the scenery. Every rule below is diegetic---a thing in the garden or a property of the rake---and every one was tested with the solver on random gardens before being kept. Here are the candidates that survived, in the order the game introduces them, and then the ones that did not. ## Ripple Stones [Rings.]{.margin-note} A **ripple stone** (marked with faint concentric rings in the abbot's sketch) must be encircled: its 8 neighboring cells must be raked as one unbroken arc, entering once and leaving once. This is _mizumon_, the rings-around-an-island motif, as a hard constraint instead of an esthetic tendency. [Texture, not tightness.]{.margin-note} The surprise of testing was how little it constrains. In the garden below, the ripple rule cuts the number of valid rakings from 183,986 (with an ordinary stone) to 35,570, and leaves par unchanged at 12; 4 of the 5 par rakings already ringed the stone. Across random 7×7 gardens with one ripple stone (and 0--2 plain stones), the median number of rakings was still 5,573. Ripple stones teach "rings are entered from outside and left from the cell next to the entrance" and make the reveal prettier, but they cannot make a garden tight on their own. ![*A ripple stone.*
(*Left*) The abbot's sketch marks the stone with rings: its 8 neighbors must be raked as one unbroken arc.
(*Right*) The par raking (12 turns; 4 of 35,570 rakings) starts at the stone and spirals outward, the ripple becoming a whirlpool.](/doc/design/2026-10-03-gwern-samon-ripple.png){.width-full} ## Wave Bands [Lines that run through.]{.margin-note} A **wave band** is a hatched region of the sketch: inside it, every row (or column, for a vertical band) must be raked as one straight, unbroken run, edge to edge. This is _sazanami_: the long parallel ripples that most gardens are mostly made of. Mechanically, each row of the band becomes a corridor that must be crossed in one go, so the player must plan where the turnarounds go outside the band. [Somewhat tighter.]{.margin-note} Bands constrain more than rings: in the garden below, the band cuts 1,492,008 rakings to 2,984, and raises par from 16 to 17 (the calmest raking of the bare garden does not run its lines where the abbot wants them). But across random 7×7 gardens with a band (and 0--2 stones), the median count was still 3,799. ![*A wave band.*
(*Left*) The hatched band must be crossed by straight runs, edge to edge.
(*Right*) A par raking: 17 turns, one of 34 at par among 2,984 valid rakings.](/doc/design/2026-10-03-gwern-samon-waves.png){.width-full} [Ichimatsu.]{.margin-note} Alternate horizontal and vertical bands in a checkerboard and you get _ichimatsu_. Here the band rule finally becomes tight, because every cell now belongs to a run and the stroke can turn only at run ends, handing off from one run to the next: the 9×9 garden of nine 3×3 blocks below has exactly 2 valid rakings, versus 54,986,690,944 for the empty 9×9 garden, and the 6×6 version (four blocks) has exactly 1 for most gate positions. These make good end-of-world showpieces: a real checkerboard _samon_, drawn in one stroke. ![*Ichimatsu.*
(*Left*) Nine 3×3 blocks, raked alternately across and down.
(*Right*) One of its 2 valid rakings (both 45 turns).](/doc/design/2026-10-03-gwern-samon-ichimatsu.png){.width-full} ## The Master's Grooves [Continue his lines.]{.margin-note} The abbot has begun the raking and been called away: a few cells already carry grooves, and the finished garden must be one stroke that runs *along* them. (Re-raking a groove in its own direction leaves no trace, so the novice may pass through these cells, but only following them.) These are the "partial raking" of the original sketch, and are the game's general-purpose clue: unlike abstract numbers or symbols, a given groove is self-explanatory, since it *is* a piece of the answer. [Uniqueness for ~5--8 grooves.]{.margin-note} Grooves are how free gardens become strict ones. The level generator picks a beautiful raking as the intended answer---a par raking with no U-turn in open sand---and adds grooves from it until it is the only valid raking left. In the garden below, 5 grooves cut 12,375 rakings to 1, and the answer is a par raking (21 turns; 1 of 11 at par). Over 69 generated 7×7--7×8 gardens, the median was 7 grooves. ![*The master's grooves.*
(*Left*) Five cells are pre-raked; the stroke must run along them.
(*Right*) The unique raking (21 turns, which is also par).](/doc/design/2026-10-03-gwern-samon-grooves.png){.width-full} [Two kinds of groove.]{.margin-note} The abbot can also leave *finished* work: a closed ring raked around a stone, as real gardens have. A single stroke can never close a ring, so these are effectively obstacles---and a way to put the iconic closed ripple into gardens without violating the one-stroke rule. They are also the "partial raking which makes an insoluble problem soluble" of the sketch: pre-raked cells are removed from the stroke's job, which changes the garden's topology (and, if the finished shape covers unequal numbers of dark and light cells, its parity). ## The Abbot's Pebbles [Straight or turn, nothing more.]{.margin-note} A weaker clue than a groove: the abbot leaves a pebble on a cell. A pale pebble means the stroke runs straight through that cell, across or down; a dark pebble means it turns there, either way. A pebble's cell is never where the rake is set down, since that cell has only one connection. (The "any turn" clue comes from a parallel design by GPT-6 Astra; Nikoli's [Masyu](!W) uses white and black pearls the same way, with extra conditions on the neighboring cells that pebbles do not have.) [More clues, harder deduction.]{.margin-note} We made 88 generated 7×7--7×8 gardens unique both ways, from the same intended raking (a graceful par raking) by the same greedy procedure. Grooves needed a median of 7 clues and pebbles 11 (more in 92% of gardens), but the pebble gardens were much harder: 92% needed lookahead by our grader (median 7 steps) and 12.5% nested lookahead, against 47% (median 2 steps; 58% in the pipeline's separate sample, [below](#generating-gardens)) and none for grooves. A groove gives away a piece of the answer; a pebble is a disjunction to be resolved against its neighbors, which is the kind of deduction the strict worlds want. So pebbles come after grooves in world 4, and supply the hardest strict gardens of world 7. Below, 8 pebbles leave exactly one of a garden's 59,498 rakings; the same answer needs only 6 grooves, which make the garden tier 1, where the pebbles make it tier 3. ![*The abbot's pebbles.*
(*Left*) A 7×7 garden made unique by 6 grooves: tier 1, every step forced.
(*Middle*) The same garden and answer made unique by 8 pebbles (pale: straight through; dark: a turn): tier 3, 14 lookahead steps.
(*Right*) The only raking: 13 turns, which is par (6 of the bare garden's 59,498 rakings reach it).](/doc/design/2026-10-03-gwern-samon-pebbles.png){.width-full} ## The Wide Rake [No U-turns in open sand.]{.margin-note} The novice is promoted and given the senior's rake, which is too wide to pivot. With it, the stroke may turn 90° anywhere, but it may make a tight U-turn (two consecutive turns the same way) only when the U is braced against a wall or a stone; and the rake must be set down in a cell touching a wall or a stone, never in the open. The abbot's finished sand (his closed rings, above) is neither: turning against it would rake it. A reversal in open sand therefore needs *something inside it*: a stone to wrap around, or a nest of wider U's around a stone. The esthetic motivation is the same as for counting turns: a groove that doubles back on itself in open sand is the ugliest thing a raking can contain, and in real gardens lines end at walls and stones, not in open gravel. ![*The wide rake.*
(*Left*) A tight U-turn in open sand is forbidden.
(*Middle*) Against a wall, it is allowed.
(*Right*) A wider U-turn around a stone is allowed.](/doc/design/2026-10-03-gwern-samon-widerake-rule.png){.width-full} [It tightens everything.]{.margin-note} This one physical rule does what the pattern constraints could not. In empty gardens it leaves 2--8 rakings out of up to millions (see the first table); in random 7×7 gardens with 2 stones, 18% are solvable and the median solvable garden has 2 rakings; uniqueness, which the bare rule did not produce in any of the 1,200 random 7×7 layouts, appears by itself (2.7% of 2-stone layouts). Forbidding every U-turn, braced or not, is too much: no empty garden of any size we tried, and none of 1,200 random 7×7 gardens, could be raked at all. [Often the calm answer.]{.margin-note} The striking result is *which* raking survives. The 8×8 garden below has 6,310,226 valid rakings under the bare rule; 12 of them reach par (17 turns); exactly one of those 12 has no U-turn in open sand; and that one is the garden's *only* raking under the wide rake. This is not a fluke of one garden, though it is not a law either: over 964 random gardens with a unique wide-rake raking, that raking was a par raking of the unrestricted garden in 32% of cases, and in 47% of the calm-looking ones (turn density ≤0.40) that the curator would keep. So the novice's tool often selects the very raking the abbot would have chosen; but it is a stricter rule than his taste, not the same one (see [Free, then strict](#progression)). ![*A wide-rake garden.*
(*Left*) Two stones.
(*Right*) Its only valid raking under the wide rake (17 turns): a spiral around one stone, a horseshoe around the other, parallel lines everywhere else. It is the only one of the garden's 12 par rakings without a U-turn in open sand.](/doc/design/2026-10-03-gwern-samon-widerake.png){.width-full} [Hard.]{.margin-note} Wide-rake gardens are the hardest in the game by our grader: every one we generated required lookahead (assume a move, propagate, find a contradiction), and about a quarter required *nested* lookahead. The player-facing heuristics---"every reversal needs a pivot", "count the cells inside each U", "lines run wall to wall"---are learnable, but this needs playtesting more than anything else in the design, and is why it comes fifth rather than second. ## The Side Gate [Both ends fixed.]{.margin-note} Some gardens have a second, side gate, and the novice must rake his way *in* from it (walking backwards from the start, so leaving no footprints at all) and out by the veranda. Fixing both ends of the stroke doubles the force of parity (both endpoint colors are now given) and removes the player's freedom of where to begin. The stroke comes in through the wall, so a bend in its first cell counts as a turn, as leaving by the veranda gate does. Under the bare rule this is still loose (the garden below has 311,080 such rakings); combined with the wide rake it gives a unique, graceful answer that is also par. ![*The side gate.*
(*Left*) The stroke must begin at the side gate (top, outlined) and end at the veranda gate.
(*Right*) The only valid raking under the wide rake: 20 turns, counting the bend just inside the side gate, which is also par for the bare rule (32 of the 311,080 rakings reach it).](/doc/design/2026-10-03-gwern-samon-twogates.png){.width-full} ## Rejected Mechanics {.collapse} Mechanics we considered and dropped, with the reason for each. - **Ice-sliding**: _Zen Puzzle Garden_'s rule (the monk slides until blocked, and may only turn when blocked) is the obvious alternative core. It is taken, and it makes the puzzle about stopping places rather than shapes. - **Braced turns only** (90° turns allowed only next to walls or stones): a softer cousin of ice-sliding. Too strict: only 31% of empty 7×7 gardens remain solvable, and almost no gardens with stones. - **No U-turns at all**: infeasible; see above. - **Pushable lanterns, ordered leaves**: _Zen Puzzle Garden_'s later additions ([JayIsGames](https://jayisgames.com/review/zen-puzzle-garden.php "'Zen Puzzle Garden', JayIsGames")). They import Sokoban into a game whose identity is the line. - **Repairing a disturbed garden** (a deer walked through overnight; re-rake only what you must): it sounds rich but is trivial. For the repaired garden to be one stroke, the grooves the novice leaves untouched must be an initial segment of the old stroke, ending where his new raking begins; so the cheapest repair is always to re-rake everything from the first damaged cell (in the old stroke's order) back to the gate, and the route he takes doesn't matter. - **Multiple monks / multiple strokes / stepping stones that lift the rake**: each adds slack to a rule whose problem is already slack. - **Bridges over a dry stream** (the only cells the stroke may cross): workable (_Flow Free: Bridges_ does it), but untested here, and it weakens "never cross your own lines", the rule that makes the fiction work. - **Symmetric gardens** (mirror-image halves raked in mirror image): against the grain of the esthetic, which prizes _fukinsei_, asymmetry ([Japanese aesthetics](!W)), and impossible anyway. A symmetry of the garden that preserves the stroke maps the path onto itself, and a path's only symmetries are the identity and reversal; reversal would swap the gate's cell with the rake's start, so it must be the identity, which fixes every cell of the stroke: all the sand would have to lie on the mirror line (Astra's argument). *Local* symmetry, two marked patches raked in mirror image, is possible; Astra proposes it as an optional rule, untested here. # Progression [Seven worlds, one day.]{.margin-note} The worlds follow one day at the temple, dawn to moonrise, which gives each a lighting (and so a look: low sun makes grooves cast long shadows; noon flattens them; moonlight silvers them) without any text. Each introduces exactly one rule; gardens grow from one-cell corridors to 9×9. | World | Time | New rule | What it teaches | Gardens (main path + extras) | |-------|------|----------|-----------------|--------:| | 1. First Stroke | Dawn | One stroke from the gate; stones | A dead end is where the walk in must end; corridors force the route; parity | 9 + 10 | | 2. Ripples | Morning | Ripple stones | Rings: entered once, left beside the entrance | 10 + 10 | | 3. Waves | Noon | Wave bands; _ichimatsu_ | Runs are atomic; plan turnarounds outside them | 11 + 9 | | 4. The Master's Grooves | Afternoon | Given grooves (open & closed); then pebbles | Continuation; reading a unique answer; resolving "straight or turn" against the neighbors | 16 + 9 | | 5. The Wide Rake | Evening | No U-turn in open sand; set rake down at an edge | Every reversal needs a pivot | 9 + 17 | | 6. The Side Gate | Dusk | Two gates | Both ends fixed; double parity | 9 + 9 | | 7. Silver Sand | Night | All of the above, larger gardens | Synthesis | 8 + 12 | Table: World structure (148 gardens, all in the prototype: 72 on the main path, 76 optional extras). Worlds 1--3 are mostly *free* gardens (many valid rakings, scored against par); worlds 4--7 mostly *strict* (one valid raking). [Free, then strict.]{.margin-note} The arc runs from optimization to deduction. In worlds 1--3 any valid raking finishes a garden and the seal rewards calm; the player learns, by seeing their own rakings next to par, that calm rakings are long lines and rings. From world 4 on, gardens have one valid raking, and the rules (grooves, then the wide rake) *enforce* a calm that the first half only rewarded. It is not quite the same calm. The generator keeps a free garden only if *some* par raking has no U-turn in open sand, but par is usually reached by others too: in 42 of the 54 free gardens of worlds 1--3 the par rakings include both kinds, and 327 of all 407 par rakings there turn back in open sand somewhere (_Waves_, for instance, has 34 par rakings, and 32 of them do). So the wide rake is a stricter rule than the abbot's taste, to be learned as a new rule, not the taste made physical (Astra's correction: an earlier version of this page claimed the latter). Curating the free worlds so that every par raking is graceful would leave too few gardens (11 of 54 qualify), so instead the wide rake's introduction makes the difference visible: its first garden is one whose par raking turns back in open sand (below). The seal works the same way throughout; in a strict garden every valid raking is at par, so there it marks a garden raked without the abbot's help. The final world is named for the "sea of silver sand" at [Ginkaku-ji](!W), raked "so that in moonlight its ridges appear as ripples in water" ([Cartwright 2019](https://www.worldhistory.org/Ginkakuji/ "'Ginkakuji', Cartwright 2019")). [A short main path.]{.margin-note} Each world has a short main path, which is the template below and nothing more (8--16 gardens; 72 in all), followed by optional extras (76), listed after it as "more": the rest of the generated gardens, for practice and for seals. In the first version of the campaign every garden was on the path, so grooves came after 38 gardens and the wide rake after 52 (Astra's count, from the unlocking rule); now they come after 21 and 32. [Opening worlds.]{.margin-note} A world opens once two-thirds of the main path of the world before it has been raked, at any number of turns; extras raked instead count too. Par is never required, and any garden can be finished, by persistence or with the abbot's help, so no player is ever locked out; within an open world every garden is open, so a player stuck on one rakes others. [The first five gardens.]{.margin-note} Teaching is wordless, by construction: (1) a one-cell-wide corridor (drawing, and the reveal); (2) a 3×3 garden with a corner gate (more than one answer, and U-turns at the walls; the turn counter and par appear); (3) the first real garden, the easiest of world 1's generated ones (5×5, one stone), where long lines turning at a stone first appear; (4) a garden with a one-cell pocket behind a stone (the pocket can only be where the rake is set down); (5) a garden of two-tone gravel in which the obvious place to end the walk is the wrong color, so every attempt ending there strands a cell (parity, made visible; the two tones fade to uniform white over the next two gardens). The real garden comes before the two techniques, which only matter at its size: an early player, on a phone, did not "sense the potential until about six levels in", which is where the first real garden then came. A 2×3 garden that taught the U-turn was dropped to make room; the 3×3 garden's par rakings turn back at the walls anyway. [Inside a world.]{.margin-note} Each world after the first follows one template (Astra's): an introduction (a contrasting pair, below); two applications; a garden whose obvious move, learned from the gardens before it, is wrong; two combinations with earlier rules; a calm, easy garden; and a capstone. Every garden is stored with its intended raking, the one observation it teaches, and the abbot's first hint, which states that observation; the test for keeping a garden is *what fact changes the player's plan?* A garden is cut if the only way to solve it is trial and error, or if its clues trace the answer outright. [Show the change.]{.margin-note} Each later rule is introduced by a contrasting pair (also Astra's suggestion): a small garden raked as usual, then the same garden with one change, both found by search. A ripple stone, a band, or the wide rake is added to a garden whose every par raking breaks the new rule, so the plan that just worked visibly fails; one groove cuts a garden's 8 rakings to one; pebbles, one pale and one dark, replace the two grooves that pinned another garden down; a side gate leaves one of a garden's two wide-rake rakings, and not the par one. The pairs have 17--23 cells of sand; the first campaign introduced bands in a 55-cell garden, and grooves and pebbles in 47-cell ones, which now come after two smaller applications. The changed garden of each pair is demonstrated before the player rakes it (see [Interface](#interface)). [The campaign.]{.margin-note} The prototype has all 148 gardens. Thirty-four are teaching gardens: 13 of the prototype's original 14 (the 2×3 garden was dropped), the corridor, three _ichimatsu_ variants, four small introductions found by search (among them the two-tone parity garden), and the 13 gardens of the contrasting pairs. The other 114 come from the pipeline below, with garden-like stones: for each size and mechanic, it generates up to twice as many candidates as needed (235 from 16,437 sampled layouts, in 33 minutes of computing) and keeps a subset spread over their difficulty range, no two alike up to rotation and reflection (apart from the pairs, which are alike on purpose). Half of that time went on failing to find a single 9×9 pebble garden (none of 227 layouts passed the filters), so world 7's pebble gardens are 8×8. [Ordered by the template.]{.margin-note} Each world is put in the template's order, with every role assigned by measurement. Applications run from easy to middling (the hardest are extras): free gardens by size, then by how rare par is among their rakings; strict gardens by grade. The trap is the garden whose obvious move is most clearly wrong: in a free garden, the one whose key observation (below) the near-misses, rakings one or two turns over par, most often get wrong (between 74% and 94% of them, in worlds 1--3); in a strict garden, one whose only raking turns aside at once, where walking straight in from the gate can be refuted only by lookahead. The combinations add a rule from an earlier world (a second ripple stone; a ripple stone among bands; grooves or pebbles with a ripple or a band; the wide rake with either; pebbles at a side gate), generated for the purpose. The calm garden is the one with the fewest turns per cell among the easier half of the rest, and the capstone is the hardest, or a hand-made showpiece (_Calm_, _Spiral_, _Side gate_, _Silver sand_). A rule introduced mid-world (_ichimatsu_; the abbot's finished rings; his pebbles) gets its own short run of gardens after the trap. [Hints that change the plan.]{.margin-note} Each generated garden's first hint is the one provable fact that most changes the player's plan, and the abbot points at the cells it names. In a free garden, it is the fact true of every par raking (where it ends; how it begins; a row, column, or stretch of one raked in one straight line; whether the line turns or runs straight in some cell) that the most near-misses get wrong: a median of 50% of them. In a strict garden, it is the fact about the only raking (where the rake is set down, or the line's shape in one cell) that most lowers the grade; when the rake is set down in a cell with one way in, the hint says so, giving the reason before the place. Of the 80 strict gardens with such a hint, 62 need lookahead without it; with it, 16 need none, the 10 that needed nested lookahead need only one level, and the other 36 need fewer steps. Of the 18 that need no lookahead anyway, it lets 4 be solved by local rules alone (the other 14 already are). In a strict trap, the observation is the trap itself: walking straight in goes wrong. None of this is curated or playtested: it is the raw material for step 3 of the [production plan](#production-plan), not its result. [Daily garden: _ichi-go ichi-e_.]{.margin-note} One generated garden per day, the same for everyone, alternating free and strict. The player may draw and undo freely, and here nothing is raked until they press **Rake** (the only garden with the button); that first raking is the day's record---"one time, one meeting" ([_ichi-go ichi-e_](!W "Ichi-go ichi-e")). The result shared is the seal (or not) and turns versus par, with the garden's shape but not the stroke, which spoils nothing (everyone else is still raking it that day). The prototype has it: a year of 366 gardens made by the campaign's generators and never used in it (even days free, odd days strict; 5×5 to 8×8; ripple stones, bands, grooves, pebbles, the wide rake and side gates all appear, with the rule stated in a line, since the daily garden does not wait for the player to reach the world that teaches it), picked by the local date, so everyone gets the same garden on the same day and the year repeats. Drawing and undoing are free; **Rake** commits; the raking is then final for the day, with the record, the poem (the campaign's, by day of the year) and a streak of consecutive days shown, a share to pass on ("Samon · daily garden 2026-10-04 · 14 turns, par 12 · 静 · streak 3", the garden's shape drawn in box-drawing characters without the stroke, and a link that opens today's garden: see [Sharing](#sharing) below), and a new garden tomorrow. [LinkedIn's _Zip_](https://www.gmpuzzles.com/blog/2025/03/introducing-zip "'Introducing Zip', Snyder 2025"), a daily Hamiltonian-path puzzle, suggests the appetite is there. # Generating Gardens [Pipeline.]{.margin-note} Handmade levels set the curriculum; generated ones fill it, and supply the daily garden. The pipeline: #. Sample a layout: garden size, stones, gate on the veranda wall, and the world's markings. Stones are placed the way a garden designer would: an odd number of pieces (1, 3 or 5), each of 1--3 cells (single, straight or L-shaped), none within a cell of another, with no mirror or half-turn symmetry, and covering at most ~15% of the garden. (The experiments elsewhere on this page used single-cell stones; the two samplers are compared below.) #. Reject on parity (instant). #. Count valid rakings with the exact solver (milliseconds for ≤8×8; the empty 7×7 garden's 1.5 million rakings take 2 seconds). #. Prove par with the constraint solver, and also the best turn count achievable with no U-turn in open sand; reject free gardens where the two differ (their par raking is a meander, and the seal would reward an ugly answer). #. Strict gardens: keep wide-rake layouts that are unique by themselves, or add grooves or pebbles from the graceful par raking until it is unique. #. Grade with the deductive solver (below), and reject or reorder. #. Curate the survivors for layout esthetics (a learned model could help here; see above), and write each one's observation and first hint. [Yields.]{.margin-note} Strictness is cheap to generate. Of 1,151 random 7×7--7×8 layouts, 69 (6.0%) passed all filters for groove gardens, needing a median 7 grooves. Of 95,673 random 7×7--8×8 layouts with 1--3 stones, 2,444 (2.6%) were unique under the wide rake with no grooves at all; 58% of those had a turn density (turns per cell) of at most 0.40, which in practice separates calm answers from meandering ones. At these rates, a year of daily gardens takes on the order of a CPU-hour; the prototype's year took 8 minutes of generation on two cores (`src/daily.py`), plus the campaign's unused candidates. [Garden-like stones cost nothing.]{.margin-note} Realistic stone groups do not make good gardens rarer. On 1,000 random 7×7 layouts from each sampler (garden-like layouts averaged 4.7 stone cells, single-cell ones 2.0), fewer garden-like layouts were solvable at all (28% against 39%), but the filters discard those anyway: usable free gardens came slightly more often (4.7% of layouts against 3.8%), gardens unique under the wide rake about as often (2.2% against 2.7%), and unique side-gate gardens slightly more often (1.8% against 1.3%). Groove gardens needed the same median of 6 grooves; on small samples (25 each), the garden-like ones needed lookahead less often (28% against 84%), plausibly because larger stones make more corridors, which are local deductions. [Grading.]{.margin-note} Difficulty is estimated by a deductive solver that works like a human, on the state of each boundary between cells (raked through, or not): - *Tier 1*, local rules: a cell with two raked boundaries closes its others; a cell with exactly two open boundaries uses both; a cell with one is where the rake is set down; no closed loops; grooves, pebbles, bands, rings, and the wide rake's ban on open U-turns. - *Tier 2*, global rules: checkerboard parity of the starting cell, connectivity of the unraked sand, and no premature joining of the stroke's two ends. - *Tier 3*, lookahead: assume a boundary is raked through, propagate tiers 1--2, and reject it on contradiction. - *Tier 4*, nested lookahead. Generated groove gardens were 38% tier 1, 4% tier 2, and 58% tier 3 (median 3 lookahead steps); wide-rake gardens were 74% tier 3 (median 16 steps) and 26% tier 4. Within a world, gardens follow the template ([Progression](#progression)), and its strict applications are ordered by tier, then lookahead count, then size. These tiers are a proxy; only playtesting can say whether, for instance, the wide rake's lookahead is the kind players enjoy or the kind they resent. # Interface [Show, then do.]{.margin-note} A garden that brings in something new is first raked once by the monk himself: a ghostly fingertip leads him in from the gate along the intended raking, he rakes his way back out, the result rests for a moment ("That is a calm way to rake it: 3 turns, which is par. Now you."), and the garden is smoothed for the player. This happens in the first garden (what raking *is*: in play, the first garden alone left it unclear what to do), in the first one scored by turns, and at the introduction of each later rule; it plays once by itself, **Watch** replays it, a tap skips it, and it costs nothing. Watching the answer to an introduction spoils little, since an introduction is not meant to be hard; what it teaches is what the rule *looks like* when obeyed. [Draw from the gate.]{.margin-note} Before the walk, the monk waits in the gateway, with a ring that pulses around him three times and then rests (it pulsed without end at first, which nags once the idea has been had); the player drags him in, and the path snaps cell to cell and leaves footprints. Until a player's first walk, and always in the first garden, a ghostly fingertip then demonstrates the drag itself, three times; a press anywhere else says where to begin, and demonstrates again. (The prototype first marked the gate's cell with a dashed circle, which says nothing about where to start, or that one drags.) The walk follows the *direction* the hand is moving, not the cell the pointer happens to be in: arms are not mounted on tracks, and a hand sweeping along a row drifts off it, so a drag from one end of a row to the other that strays into the next row still draws the row (the first prototype snapped to whichever cell the pointer entered, and a long run would hop rows partway). The axis of motion holds until the motion itself turns to within 30° of the other axis, or until the pointer is two cells off the run; a turn first finishes the run as far as the pointer went, so a rounded corner comes out as a corner; the direction is read over the last 0.4 cell of motion, so a wobble in place reads as nothing; and the pointer is captured, so the grip survives leaving the field or the window, a pointer beyond the walls being read as the nearest cell. (This is the submenu problem of hierarchical menus, solved the same way: the intended movement is what matters, and a cursor that leaves the menu item on a slight diagonal toward the submenu has not left it.) Dragging back over the last footprint retracts it; tapping any footprint walks the monk back to it; tapping the gate starts over. Since either can throw away a long route by accident, the last rewind or restart can always be undone (**Restore**). A rewind says what it leaves ("Stepped back 3 footprints: par is still possible from here"), replacing whatever verdict stood before, and the garden keeps the keyboard afterwards (after Astra's note that arrow keys scrolled the page after "Step back 3 for par" and that the old impossibility message lingered). Moves into stones, walls, or footprints are ignored, and so is any move that breaks a rule on the spot (an open-sand U-turn under the wide rake, a turn at a pale pebble), with a soft scrape and a brief judder of the rake; a route that is doomed but not yet broken stays drawable, since noticing the trap is the puzzle. A turn counter and the par sit at the top of the screen; nothing else does. Whatever the game says to the player, as opposed to shows ("Drag the monk in from the gate", "Morning is open: 20 new gardens", a locked world's note, the help, the scroll's counts) is set in square brackets, as gwern.net sets the editor's words to the reader; rules, poems, counts and controls are not. Every control, count and mark carries a tooltip with the word more that a hover might want (what Par is and who proved it, what Ask the abbot costs, what a world's extras are, what the seal says), as gwern.net puts one on everything that would bear it; the garden itself has none, since a tooltip over the sand would follow the hand through every walk. [Letting go rakes.]{.margin-note} When the walk covers every cell and the finger lifts, the monk rakes; from the keyboard, the last step is followed by a beat in which it can still be taken back. Raking is not final: tapping any cell of the raked garden smooths it back to the footprints, as far as that cell, and the walk goes on from there (and **Restore** rakes it again as it was), so a player who sees 19 turns against a par of 17 backs up with one tap, having seen the pattern. An earlier design asked for an explicit **Rake** press after every garden (Astra's); in play it was a confirmation for an action that costs nothing, and it is kept only in the daily garden, where the first raking is the record. [Sharing.]{#sharing .margin-note} A raking is worth passing on, and the page has no server, so the raking itself travels in the link: **Share this raking** gives the share sheet (or the clipboard) the raking drawn in box-drawing characters---light lines for the stroke, heavy for the walls with a tee where the gate breaks them, blocks for the stones---under a line with the garden, the turns, the par and the seal, and a link whose hash holds the raking as a code. The code is small because the rules are strict about what a walk can do next: at each step there are at most three legal moves (and often one), so the walk is written as a number in the *mixed radix* of its legal moves, step by step in the order north, east, south, west, and a whole raking of a 7×7 garden takes some 20--50 bits (a mean of 22 bits over the campaign's intended rakings, against 86 at two bits a step; with the garden's identity and whether the abbot helped, 8--17 characters of base64url). The garden is named in the code by 32 bits of a hash of its layout and rules, never by its place in the campaign, so a link survives a reordering, and a garden changed since is recognized as another rather than mistaken for the one meant (Astra's point: an 8-bit check, the first version, would have collided once in 256, and a seal must never land on a different puzzle). No code decodes to a walk that breaks a rule, since the decoder re-derives the legal moves as it goes, and a code whose garden is gone or changed, or whose walk does not fit, is refused with a word. Opening such a link shows the garden raked, with its seal, and says whose it is: it is credited to nobody, the viewer's own progress is untouched, and any tap smooths it away and leaves the garden theirs to rake from the gate (a raking so received cannot be walked back into, or restored). The same idea carries a player's whole progress between devices without an account: **Carry my progress** makes a link whose hash lists every raked garden, by the same layout hash, with its state (raked and by how many turns over par, sealed in grey, sealed), the daily records by date with today's raking, and the unfinished walk of the garden last open, with the abbot's mark on it: about 7 characters a garden and 4 a day, so some 140 characters for 20 gardens and 2,500 for everything. Opened on another device, it is offered rather than taken ("This link carries a player's progress: 23 of 148 gardens raked, 19 sealed, 4 daily gardens... Take it over here?"), and taking it joins the two: whichever side has the better state of a garden keeps it, the first raking of a day stays its record, and an entry whose garden is not in this edition, or has changed, is left out and said so, never assigned to the garden now at its place. A link ending in `#daily` opens today's garden. (The alternative, a shared raking as its index in a canonical enumeration of the garden's rakings, would be smaller still for free gardens, but a million-raking garden cannot be enumerated in the browser; the mixed radix is the same ordering, counted from one end, without the enumeration.) [Phone geometry.]{.margin-note} Portrait phones are ≈390 points wide, so a 9-cell garden gives ≈42-point cells, about the 44-point minimum touch target; that sets 9×9 as the largest garden that needs no zoom, and the design never exceeds it. The same interface works with a mouse. [The reveal.]{.margin-note} When the walk is complete, the monk turns, sets down the rake, and walks backwards to the gate, pulling it; then the camera tilts from top-down to the abbot's view from the veranda, low sun on the ridges, and the seal is stamped or not. The rake must glide: it follows the stroke's centerline continuously, through every quarter-circle, at a walking pace that eases in where it is set down and out at the gate, carving lit grooves and erasing the footprints as it passes, with a few grains flicked aside by the tines. (The prototype's first reveal drew finished cells one at a time as line art; it read as chunky and unsatisfying, and the reveal is the game's payoff, so it is worth the most polish.) It lasts a few seconds, scaled to the garden's size, and a tap skips it. [The abbot judges your route, not his.]{.margin-note} Asking the abbot never reveals *his* answer; it decides, by search, what the player's own route can still become, and says only what it can prove. Gardens this small are decided quickly (the slowest of the prototype's 4,440 test routes took 0.7 seconds in a desktop browser). There are three verdicts. The route can still be raked at par: he sketches its next groove, taken from a par raking that continues the player's route. It can still be finished, but not at par: he marks the last footprint from which par was possible and says so with the action first ("step back 3 footprints to the marked footprint for par"), and offers the step. It cannot be finished: he says why when there is a short reason (sand cut off, two dead ends, the wrong count of dark and light cells), marks the cells involved, and offers to step back to the nearest footprint from which the garden can still be finished, proven to be the nearest (if, in a large garden, that proof runs out of budget, he offers a footprint from which it can be finished, without calling it the nearest). In a fresh garden the first request gives instead the garden's observation ("the top-right corner has only one way in"). If a search ever runs out of budget, he says he cannot tell yet, rather than guess. [Help costs the vermilion.]{.margin-note} The observation is free, and so is the reason a walk has *stopped* (no step on is allowed, or every cell is covered but the walk can't end there), since a player can see that much already; an answer the abbot cannot give in time is free too. Anything more---a verdict on a live route, a sketched groove, where to step back to---marks the garden as helped, and a helped raking at par earns the abbot's seal in grey ink instead of vermilion. Astra argues for making every diagnosis free and charging only for a revealed move; but a verdict on a live route ("this can still reach par") is an oracle, which a player can consult after every step to search the garden mechanically, and the seal would then certify nothing. The mark lasts until the garden is next raked, so starting over does not wash it out, and it travels with the route it helped: walking back into a helped raking and restoring it rakes it grey again (an early version let Step back and Restore turn a grey seal vermilion; Astra found it). Raking the garden again unaided can still earn the vermilion seal, and the garden list keeps the better of the two (seals are drawn square, rakings off par as round dots). The redemption is deliberate: a player can memorize a helped answer and rake it again, but making one peek cost a garden's seal forever would punish curiosity more than it rewards honesty. Assists (below) do not affect the seal. This replaces an earlier design, in which a hint revealed one groove of *the* intended raking; in free gardens that hint contradicted valid routes the player was already on. (Hints that judge the player's own route, with proven rewinds, are Astra's idea.) [Assists.]{.margin-note} Off by default after world 1, on toggles: a faint preview of the grooves under the footprints; the checkerboard; and hatching on sand the route has cut off from the gate. There are no timers, move limits, or failure states; an impossible position is simply one the player has not yet noticed. [Below the garden.]{.margin-note} Under the garden sit the turn count against par and three buttons (Step back, Start over, Ask the abbot; Restore, Watch, Rake and Next garden appear when they apply), then the status line, the assists (left in view, since a new player cannot know what an assist is until shown), a garden's rule where it brings one in, the list of gardens, and the help, folded into one line. The list shows the worlds that are open, each as its main path, with the extras folded behind "+10 more" (unfolded by themselves while one of them is the garden open); a world not yet open is one muted line with its count and what opens it, so a new player sees a short list that lengthens as the day goes on. Sound, volume and haptics live under a chevron beside the speaker in the header rather than in the list; the counts of a garden's rakings live in the tooltip of its par. (The first version showed every world's pips, greyed, the three sound controls in the list, a "Sand" counter, the rule for every garden, and 290 words of help; on a phone the page ran to 2,400 pixels, and now to 1,400, with everything that was cut a hover or a tap away.) [Accessibility.]{.margin-note} Every rule-bearing mark differs in shape, not only in color: hatching for bands, dashed rings for ripple stones, pale against dark pebbles, a dashed outline for cells the abbot points at, hatching for cut-off sand. The game is fully playable from a keyboard (arrows walk, Backspace steps back, Enter skips the reveal and goes on) and respects reduced-motion settings, as the prototype already does. A text description of each cell (terrain, mark, footprint, monk) with a coordinate-grid view would support non-visual play; that needs testing with screen-reader users rather than assuming. [Prototype.]{.margin-note} A one-page HTML prototype, included with the code, implements this interface without the camera tilt: worlds that open in turn (with a switch that opens them all, for testing), main paths and extras, the monk's demonstrations and the first-drag hint, the monk waiting at the gate, drawing, rewinds and Restore, the turn counter against par, raking on letting go and stepping back after it, the refusals, the abbot's three verdicts, the three assists, the gliding reveal, the vermilion and grey seals, the poem after each raking and the fade to the next garden, the scroll of poems earned, the daily garden, the practice garden, the sound (the synthesized pipe, drips, wind, birds, crickets, footsteps, scrape, bell and _tok_; the shakuhachi recording; haptics), and the sharing (rakings and progress as codes in the URL's hash) (all in [Presentation](#presentation) and [Progression](#progression)). It has the whole campaign of 148 gardens (see [Progression](#progression)), each with its poem; every par and raking count shown in it comes from the solvers, and its hint search agreed with the C solver on every one of 4,440 test routes (none ran out of budget). Where the game would teach wordlessly, the prototype states each new rule in a line of text. # Presentation [Sand as a heightfield.]{.margin-note} The original sketch suggested a falling-sand particle simulation. Raking does not make sand flow; it displaces it, so the right representation is a *heightfield* computed per pixel in a fragment shader: each cell's groove type (straight, quarter-circle around the corner, or the rake's start) gives the distance to each tine's centerline, a smooth groove profile turns distance into height, and the height's gradient is lit by a low sun whose angle follows the world's time of day. Footprints are depressions stamped by decal; the reveal is a mask advancing along the stroke. This is cheap on any phone, resolution-independent, and gives the one thing that matters visually: ridges catching light. The prototype does a simpler version on the CPU, with about one height sample per screen pixel (32 to 144 per cell, so the sand is never visibly upscaled): footprints are pressed into the field as the player walks in, the abbot's grooves are raked into it in advance, and the reveal writes the rake's cross-section along the stroke frame by frame, the same engine as the practice garden (below). Gravel grain is procedural and never the same twice: two octaves of value noise in the height (so the ridges catch light unevenly), a per-pixel fleck and a slow mottle in the color, a few specks on top, and a fresh seed every time a garden is laid out. The rake is set down flush with whatever is behind its first cell (wall, stone, or the lane raked before), so no patch of sand is left between the start of the stroke and its neighbor. The grid is an abstraction, and the rendering admits it: a turn is drawn as an ideal quarter-circle stamp, which no rigid rake sweeps exactly; the picture is faithful to the stroke, not to rake physics. [Practice garden.]{.margin-note} A garden with no rules is always open: drag anywhere to rake, curves and all; place or lift stones (the sand under a lifted stone stays unraked); smooth it flat and start again. Here the heightfield is written directly: the rake head, held perpendicular to its motion, sets the sand's cross-section (a ridge between every pair of tines) along its path, and the same low sun lights it. It costs almost nothing, and it is the honest home for the sketch's falling-sand toy: a sandbox for the hands, kept apart from the puzzles so it never weakens their rules (Astra's suggestion). The prototype has one. [Palette.]{.margin-note} White-grey granite gravel, _sumi-e_ ink for stones and walls, moss green around the larger stones, and one accent: the vermilion of the novice's _ensō_ and the abbot's seal (the two marks of a brush in the garden; the first prototype had a vermilion triangle in the gateway as well, redundant with the gap in the wall and with the monk waiting in it, so it is gone, and only a side gate's exit keeps an outline triangle). No sepia or yellow cast; the gravel at Kyoto temples is white. The stones are ink-wash paintings, not 3D models; [_Ōkami_](!W) shows how far _sumi-e_ can go in motion. In the prototype each stone is drawn as one: a contour that is lumpy and a little rough, flatter at its base where it sits in the sand; a soft contact shadow cast down and to the right, where the sand's light is from, as the blurred shadow of one path (so the two parts of a bent stone never darken each other), with a tighter, darker one at the base; an ink wash lighter toward the light; and a brushed edge, one band of varying weight, heavy where the ink pools on the shadow side and thinning to nothing where the brush ran dry on the lit side (in dark mode the edge is the pale rim light, so it is heavy on the lit side instead). This replaced the first prototype's smooth capsules with a highlight, after GPT-6 Astra's second review of it; a first attempt with per-segment strokes and stepped washes looked jagged and banded, which Gwern caught, so everything is now one smooth band or gradient. The novice is an [_ensō_](!W): one brushed circle, coming down rounded and heavy, thinning as it goes round, lifting before it closes, with two dry strands trailing past the lift, in vermilion, rubricated like an initial (the first prototype drew a filled black disc); he is drawn at the head of the walk, and, walking backwards, behind the rake; while he waits in the gateway he is its marker. Everything around the garden follows [gwern.net's design](https://gwern.net/design): black text on white, greys for whatever is secondary, serif text with sans-serif controls, square corners, hairline rules, no shadows, and dark mode as gwern.net has it (#161616 under #f1f1f1); the vermilion stays the one colour, on the novice's ensō and the seal, and the abbot's marks, the hint highlights and the faint route through the footprints, which the first prototype tinted blue and red, are ink. [Sound.]{.margin-note} The garden has its own sounds and almost no music. A bamboo pipe drips into a stone basin now and then (every 7--23 s), and every minute or two the pipe tips onto its stone with a hollow knock and spills a few drops; a _bonshō_ bell sounds once at the close of each world, its low partials beating and lasting; the seal lands with a dull wooden _tok_. Under them a wind moves, a bed of low filtered noise that wanders in level and colour every few seconds and swells into a gust every half-minute or so; by the local clock, small birds sing by day (phrases of two to six gliding notes, every 12--45 s, from 6 to 19) and crickets trill after dark (a high sine pulsed at about 28 Hz, from 20 to 5), so a page left open follows the evening. Each footprint is a step on gravel (a burst of grains over a soft thump), and the raking is heard: the rake's scrape is grains of gravel under the tines, a looping crackle through a bandpass whose loudness, brightness and density follow the stroke's speed frame by frame, with the rasp of tines dragged sideways in every bend and the monk's backward step at every cell, lifting as the rake goes out through the gate. All of these are synthesized in the page with Web Audio (decaying sines, filtered noise and synthesized crackle; a short feedback delay for the basin's echo), so they cost nothing to carry and never repeat exactly. After a world's bell, and now and then otherwise (never within a quarter-hour of the last), a shakuhachi recording follows, faded in over 3 s and out over 4: the recordings are real and public domain, carried in the page as data: URLs with their credits and sources shown on the scroll of poems. The first is _Shika no Tōne_ (鹿の遠音, "The Distant Cry of Deer"), the Kinko-school _honkyoku_ for two shakuhachi answering each other, played by Araki Kodō III (1879--1935) on Victor 13029 between 1925 and 1935, [from Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg) (a Japanese phonogram whose neighbouring rights have expired; the de-noised derivative released as public domain by its maker), re-encoded as mono MP3 at 32 kbps (1.5 MB). Nothing sounds until the player has touched the page, as browsers require, and the first time it does a word beside the speaker says so. Silence is one tap away: a speaker button in the header (or M) mutes everything, **Sound off** in the list of gardens is the same switch, a slider beside it sets the volume, and all of it is remembered. The page also defers to what the browser and the device say. There is no CSS media query for a sound preference (Media Queries Level 5 has `prefers-reduced-motion`, `-transparency`, `-contrast`, `-data` and `forced-colors`, nothing for audio), so the signals that exist are used instead: where the browser has the [Audio Session API](https://www.w3.org/TR/audio-session/) (Safari 17), the page's session is _ambient_, so a phone's silent switch mutes it and it mixes with whatever the player was already listening to rather than stopping it; the system's media keys (pause, stop) mute it while a recording plays, through the Media Session API, which also names the piece on the lock screen; and when the browser keeps the page's audio from running after a gesture (a site set to block sound, or Firefox's autoplay policy answering _disallowed_), the speaker shows it in red instead of pretending to play. Haptics tick once per cell (8 ms, where the device can; a switch beside the sound's, shown only on such devices). [A poem for each garden.]{.margin-note} The reward for raking a garden, at par or not, helped or not, is a poem: a moment after the reveal, a classical waka or haiku is laid over the raked sand in English on a white card with a hairline border (the first prototype blurred the grooves through a frosted plate), with the poet's name set in by an indent (no dash, and no kanji: the first version put 原文 beside the name as the button for the original, and the poet's name in kanji over the source, which Gwern found clutter), and a tap on "original" adds the Japanese and its source. About ten seconds later the garden and its poem fade together and the next garden fades in, so the campaign can be played as one slow, unbroken sequence; a tap on the poem goes on at once, a tap beside it (on the sand around the card, or the walls) puts the poem away and stays with the garden (a player's note: interacting with the poem should go forth, dismissing it should go back; the first prototype had any tap stay), **Next garden** or Enter goes on at once, and nothing moves on while the page is hidden or scrolled away. The poem rewards finishing, not par, so it never punishes asking for help; the seal still records how the garden was raked. Every poem earned is kept on a scroll (**Poems**, beside the practice garden), in the order of the gardens with their Japanese and sources, with a count of what remains in each world, so the 76 extras have something to give besides seals; a garden's name on the scroll leads back to it. The poems are real, and all written before 1900 (all but one by poets who died before it): 149 (one for each garden, and one for the practice garden) by 25 poets, from Bashō (34), Teika (23), Buson (20) and Rengetsu (13) down to single poems by Tsurayuki, Izumi Shikibu and Shiki. Each is paired by image or by structure. The first garden's poem is Bashō sweeping a temple garden before he goes on ("Let me sweep the garden / before I go— / willow leaves fall in the temple."). The garden before the first ripple stone gets Ryōkan's new pond, where the frog jumps in and makes no sound; the stone arrives with Bashō's old pond. The pair of gardens that introduces wave bands gets the two drafts of his Kiyotaki poem: waves "without a speck of dust" under the summer moon, and then, as he rewrote it on his deathbed, waves with green pine needles scattering into them. The last garden of the main path gets the one classical poem about temple sand hauled and tended by monks, Bashō's at Kehi shrine, where the Yugyō abbots carried the sand in themselves: "The moon is clear / on the sand / the pilgrim monk carried." The English versions are working translations, not quotations of published ones (see [Credits](#methods)). [No koans.]{.margin-note} There is no narrative text, and no invented wisdom: apart from the rules, the only words are the poems, which are real. The novice's progress is shown by his rake (bamboo, then the wide rake) and his robes (in the prototype he is an _ensō_, see [Palette](#presentation)); the abbot appears only as a seated figure on the veranda and as his seal. Fake Zen aphorisms (or fake haiku) would cheapen exactly the thing the game is trying to be. # Prior Art - [**_Zen Puzzle Garden_**](https://www.lexaloffle.com/zen.php) (Lexaloffle 2003): raking as a puzzle, with the ice-sliding rule ("rake all of the sand without turning corners or breaking raked lines"); 64 gardens across 4 seasons, an editor, and later pushable lanterns and leaves to collect in order. Its solvability is NP-complete ([Houston et al 2012](https://arxiv.org/abs/1106.2104 "'Zen Puzzle Garden is NP-complete', Houston et al 2012")). - [**_Zen Rock Garden_**](https://apps.apple.com/us/app/zen-rock-garden/id1553652093 "'Zen Rock Garden', ZenRock 2021") (ZenRock 2021): "Just trace over all the sand panels with a single stroke!", 300+ stages, with famous temple gardens as late motifs. This is the bare one-stroke rule, so _Samon_ does not claim it; what is new here is the gate-anchored walk-in/rake-out reveal, proven-par scoring, the _samon_ constraints, and the wide rake. - **One-stroke and fill-the-board path puzzles**: _hitofude-gaki_ (一筆書き) apps; [_Flow Free_](!W) (2012: "Pair all colors, and cover the entire board"); [_Zip_](https://www.gmpuzzles.com/blog/2025/03/introducing-zip) (2025; Hamiltonian path through numbered waypoints, with walls); Nikoli genres like Numbrix/[Hidato](!W), whose given path fragments resemble the master's grooves, and [Masyu](!W), whose pearls resemble the pebbles. - **Line puzzles with deep progression**: [_The Witness_](!W "The Witness (2016 video game)") (constraints on a drawn line, taught wordlessly); [_LYNE_](https://www.appspy.com/review/8095/lyne "'LYNE Review', AppSpy 2014") (2014); [_Cosmic Express_](https://apps.apple.com/us/app/cosmic-express/id1152474226 "'Cosmic Express', Draknek 2017") (2017; routing a single non-crossing track). - **Optimization metrics**: [_Opus Magnum_](!W) and other Zachtronics games' histograms. - **Exits and pulling**: [_Stephen's Sausage Roll_](!W) (the puzzle includes getting out), and [Sokoban](!W), of which raking is a kind of inverse: one pulls a rake rather than pushing blocks. The general problem is NP-complete, as Hamiltonian path is NP-complete even on grid graphs ([Itai et al 1982](https://doi.org/10.1137/0211056 "'Hamilton Paths in Grid Graphs', Itai et al 1982")), so no simple trick solves every garden; but garden-sized instances are trivial for a computer, which is what makes proven pars, proof-backed hints, and generated levels possible. # Production Plan [Rules before art.]{.margin-note} The order of work (Astra's plan, adapted): (1) prove the rules in a plain prototype---drawing, undo, the rules, the hint search, a few gardens---before any art (done: the prototype attached to this page); (2) build a vertical slice of 12 gardens on real phones: the first five gardens, a ripple garden, a band garden, a groove garden, a pebble garden, a wide-rake garden, a side-gate garden, and a daily garden; one polished reveal; the histogram screen; (3) curate the campaign from generated candidates, several times more than needed, ordered by grade and revised by the mistakes players actually make (a first, uncurated pass is in the prototype); (4) only then the sensory layer: the shader, sound, haptics, low-power rendering, and accessibility passes. [Pass or fail.]{.margin-note} The slice passes if players can say why a route failed after asking the abbot, carry an observation from one garden to the next, and choose to rake a finished garden again for the seal. If they brute-force, shrink the gardens; if they only trace forced moves, remove clues; if the reveal feels like a wait, shorten it; if they spend their time in the practice garden instead of the gardens, the puzzles are the problem, not the sand. # Risks and Open Questions - **The free worlds may feel trivial.** Finishing an open garden is easy, and par is optional. Mitigation: keep world 1 short (9 gardens on its main path), give the seal real status, and introduce grooves (world 4) by the second hour (after 21 gardens). One early outside player raked at least 26 gardens on a phone, in the longer first campaign, and meant to play on. - **The wide rake may be too hard.** The grader says every wide-rake garden needs lookahead. Mitigation: start it at 5×5, use the hint, and order by grade; if playtesting is bad, move it to an optional post-game. - **Genre saturation.** One-stroke grids are a crowded genre. The differentiators are esthetic: the reveal, the score that *is* the beauty, the diegetic constraint vocabulary. - **Scoring details.** Decided: leaving through the gate counts as a turn, and U-turns braced against walls are not free (as they are in real raking). Both stay simple, and the turn count already rewards long lines; either would be a one-line change, but would change every par. - **Parity frustration.** Players who never discover parity will hit unwinnable endgames repeatedly. The two-tone tutorial gravel and the checkerboard assist are the mitigation; whether they suffice is an empirical question. - **Esthetic curation.** Turn density and "no open U-turns" are crude proxies for beauty. A learned layout scorer, validated against human ratings of rendered gardens, is the obvious next experiment. - **Hints as a crutch.** Proof-backed hints are strong: pressed repeatedly in a par-reachable route, the abbot sketches the whole answer. Decided after a first play session: helped rakings earn a grey seal (see [Interface](#interface)); Astra's design kept hints free and unpenalized, and its review argued for free diagnosis; explanations of a stopped walk are now free, verdicts on a live route are not. Open: whether players will memorize helped answers to re-rake them for vermilion, and whether that matters. - **Demonstrations may teach copying.** Showing the calm raking of each introduction makes the rule's look concrete, but the player then retraces it. If playtests show players learning the shape rather than the rule, demonstrate each rule on a separate, smaller garden, and leave the pair's changed garden to the player. # Methods {.collapse} How the numbers on this page were computed, and how they were checked. Everything is reproducible from the attached code. [Exact solver.]{.margin-note} A depth-first search in C draws strokes from the gate inward (the reverse of raking; turns and U-turns are symmetric under reversal). Sand cells are bits in a 128-bit mask (gardens ≤128 cells). Each node prunes on: connectivity of the remaining sand through the head (a bitwise flood fill); degree (every unvisited cell needs two usable neighbors, except at most one, which must be the rake's start); checkerboard parity of the remaining cells; required and forbidden directions per cell (encoding bands and grooves); pebbles (a pale pebble's cell needs both neighbors on one axis usable, a dark pebble's one on each axis); ring contiguity; and the U-turn rules. It enumerates the 1,510,446 rakings of the empty 7×7 garden in 1.9 seconds (33 million nodes). [Validation.]{.margin-note} Its counts for empty gardens match [OEIS A145157](https://oeis.org/A145157) (_n_ = 3--7) and [A145156](https://oeis.org/A145156) (5×4, 5×6). Against an independent brute-force enumerator (no pruning; rules checked only on complete paths) on ≈340,000 random small gardens (≈67,000 of them solvable) exercising every rule, it agreed on every count and every turn histogram; and on medium gardens it agreed with a separately-written [CP-SAT](https://developers.google.com/optimization/cp/cp_solver) model on counts, histograms, and minimum turns. Pebbles were checked the same way, on 6,000 further random small gardens against brute force and 108 medium ones against CP-SAT, with no disagreements; the grader's pebble rules never derived a contradiction in a solvable garden, and every garden it solved, it solved to the right answer (711 random gardens, 79 of them with a unique raking). Astra's review found two rules that the solvers and the prototype shared, and so could not catch by agreeing with each other: the bend just inside a side gate was drawn but not counted (in 13 of the 18 side-gate gardens then in the campaign), and the abbot's finished sand counted as a pivot for the wide rake. Both were fixed in all three solvers and in the prototype, and checked again on 3,192 random small gardens against brute force (411 solvable, 132 of those with a side gate and 149 with finished sand) and on 120 medium ones against CP-SAT, with no disagreements. The recheck also found a bug in the CP-SAT model: a sand cell walled in by finished sand has no arcs, and a circuit constraint silently leaves such nodes out, so the model "solved" gardens that cannot be raked; every cell now needs exactly one incoming arc. [Par.]{.margin-note} The CP-SAT model is a single circuit through all sand cells plus a dummy node (arcs from the dummy to any start cell, and from the gate cell back to the dummy), with straightness indicators per cell and direction, so turns = (cells − 1) − Σ straight; rings constrain the number of arcs entering the ring set to exactly 1; bands and grooves fix edges; a pebble fixes its cell's straightness indicator and excludes it as the start; U-turns are three-literal clauses. It proves par for random 8×8 gardens in 0.2--1.8 seconds, and for the empty 9×9 garden in 4.4 seconds (2 CPU cores). [Grader and generator.]{.margin-note} The grader is the tiered propagation solver described in [Generating Gardens](#generating-gardens), in Python. Groove clues and pebbles are chosen greedily (each round, of ~24 random candidate cells from the intended raking, add the one leaving the fewest valid rakings), then pruned of redundant ones. Yields count every sampled layout, including those rejected on parity. [Hint search.]{.margin-note} The prototype's hints run a JavaScript depth-first search that uses the same rule code as the player's moves and the C solver's pruning (connectivity, dead ends, parity, pebbles), with a node budget. "Can this route still reach par?" needs no search: each garden ships with every one of its par rakings, and a route can reach par exactly when it is a prefix of one of them. Two things keep the search small. It remembers every position it has proved hopeless, where a position is the footprints, the head, and the last two moves (all the rules ever look at), so a dead end reached again by another route costs nothing. And because a completion of a route also completes every shorter part of it, whether a route can be finished switches from yes to no only once along it, so the nearest footprint to step back to is found by binary search, and is proven nearest by a single proof of impossibility at the footprint after it. On 4,440 routes over all 148 gardens (random legal walks, and prefixes of real rakings), every verdict it gave matched the C solver's, and no search ran out of budget (an earlier campaign had one such route, in a 9×9 wide-rake garden, where the abbot said he could not tell yet); of the 2,011 rewinds offered from stuck routes, 1,989 were proven nearest and matched the C solver's nearest footprint, and the other 22 (in three of the largest gardens) were footprints from which the garden could be finished, offered without the claim. [Credits.]{.margin-note} Several ideas on this page come from a parallel design for the same sketch by GPT-6 Astra, "The Last Footprint" (unpublished): hints that judge the player's own route, with proven rewinds; restorable rewinds and an explicit commit (now kept only for the daily garden); the practice garden; the "any turn" clue (the dark pebble; we added the pale one); the world template and per-garden records; the production plan and its pass/fail criteria; and the symmetry argument. Its five example gardens all check out under our solver. Astra later reviewed the prototype: it found four bugs (the grey seal washed out by Step back and Restore; the uncounted side-gate bends; finished sand as a pivot; an inconclusive hint that cost the seal), a dead end explained by the wrong rule (a side gate's cell blamed on the wide rake), and the overclaim that the abbot's taste *is* the wide rake's physics, and it proposed the short main path and the contrasting pairs. Astra also chose the poems: a sourced waka or haiku, with a working translation, for each garden of the earlier 136-garden campaign. We carried them over to the 148 gardens (garden names follow layouts, so most pairings survived the reordering), paired the 13 new gardens (with 3 poems new to the selection), replaced two poems (Shiki's) whose texts we could not confirm, checked every Japanese text and attribution against a page that prints it, and revised 22 of the translations. Two further passes (ours, then Astra's review of it) replaced 25 pairings judged weak (a felicitation poem, filler, poems fastened to their gardens by a pun or by a shared noun) with better classical poems, among them Ryōkan's new pond before Bashō's old one, Bashō's master carpenter for the abbot's grooves, the Man'yōshū moon-boat for the star garden, Tamemasa's unseen carp under trembling lotus leaves, and Saigyō's snipe at the threshold; Astra also offered two original poems of its own, which we left out, so that every word the player reads is still a real poem. The texts, sources, pairings, credits and the log of replacements are in `poems/samon-poems.json`. # License [CC0.]{.margin-note} Everything here that is ours---Gwern's contributions and all the LLM-written work: this document, the design, the solvers, experiments, tests and build scripts, the level generator and the 148 gardens and 366 daily gardens it produced, the prototype and its sounds (every one synthesized in the page), the figures, the English working translations of the poems, and the ideas merged from GPT-6 Astra's parallel design---is released into the public domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/). No attribution is required; none of it may be used to claim that Gwern or a contributor endorses a derived work. The poems are classical Japanese texts, all first published before 1900, and in the public domain everywhere; the pages each text was checked against are linked beside it in the game and in `poems/samon-poems.json`. The one recording, _Shika no Tōne_ played by Araki Kodō III (Victor 13029, recorded between 1925 and 1935), is [File:Shikanotoone_new.ogg](https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg) on Wikimedia Commons, re-encoded as mono MP3. Commons records it as public domain: a Japanese phonogram whose neighbouring rights have expired under Article 101 of the Japanese Copyright Act, with the performer dead for over seventy years, and the de-noised derivative (Torsodog, 2008) released into the public domain by its maker. In the United States, sound recordings fixed before 1946 came under federal copyright by the Music Modernization Act for 100 years from publication, so a pressing from 1930 is not public domain there until 2031; Commons hosts the file as public domain in both Japan and the United States, and we rely on that. The page's look follows gwern.net's own stylesheets (colours and font stacks), which are [CC0](https://gwern.net/about#license); no fonts are bundled, and no third-party code or images are included. The design document's figures are our own renders; the puzzle counts quoted from the [OEIS](https://oeis.org/) (A145157, A145156) are facts, not copies. # See Also - [Research ideas](/idea) Solvers, experiments, the level generator, and the document's checks (src/ ) src/campaign.py — Generate the full campaign (7 worlds, 148 gardens) with the garden-like stone sampler. """campaign.py: generate the full campaign (7 worlds, 148 gardens) with the garden-like stone sampler. Each world mixes hand-made teaching gardens (the prototype's original 14, plus a few searched for) with generated ones. Generated gardens pass the pipeline's filters (parity; solvable; free gardens: par reachable with no U-turn in open sand, a calm par raking, a manageable number of par rakings; strict gardens: exactly one raking, solved by the deductive grader) and are ordered by difficulty (free: by size, then how rare par is; strict: by grader tier, lookahead steps, size). Names and intros are assigned here; first hints marked "auto" are derived and verified by export_proto.py. Writes out/campaign.json and out/campaign_stats.json. """ from __future__ import annotations import json import math import random import sys import time from copy import deepcopy from multiprocessing import Pool from zg import Garden, turns_of from zcp import optimize from gen import parity_ok from garden_sampler import garden_layout, add_side_gate, ichimatsu from clues import make_unique, with_clues, make_unique_marks, with_marks from grader import grade from levels import from_dict, to_dict OUT = "/home/claude/zen/out/campaign.json" STATS = "/home/claude/zen/out/campaign_stats.json" L = json.load(open("/home/claude/zen/out/levels.json")) NAMES_REG = "/home/claude/zen/out/garden_names.json" def key_of(g): return (tuple(g.rows), tuple(g.gate), tuple(g.end) if g.end else None) SWAP = str.maketrans("hv=|", "vh|=") # band and block orientations swap when the axes do def canon(g): """The layout (terrain, gate cell, side-gate cell) up to rotation and reflection: two gardens with the same canon look the same to a player, even if their gates face different ways or their clues differ.""" H, W = g.H, g.W pts = [(g.gate[0], g.gate[1])] + ([tuple(g.end[:2])] if g.end else []) forms = [] for tr in (False, True): for fr in (False, True): for fc in (False, True): def f(r, c): r, c = (H - 1 - r if fr else r), (W - 1 - c if fc else c) return (c, r) if tr else (r, c) h2, w2 = (W, H) if tr else (H, W) grid = [[None] * w2 for _ in range(h2)] for r in range(H): for c in range(W): a, b = f(r, c) grid[a][b] = g.rows[r][c].translate(SWAP) if tr else g.rows[r][c] forms.append((tuple("".join(x) for x in grid), tuple(f(*q) for q in pts))) return min(forms) # ---------------------------------------------------------------- generators (each returns a candidate dict or None) def cand_free(g, max_at_par=40, min_rakings=20): if not parity_ok(g): return None r = g.solve(mode=0, cap=3 * 10**6, nodelimit=2 * 10**9) if r["nsol"] < min_rakings or not r["complete"] or r["capped"]: return None free = optimize(g, time_limit=20) grace = optimize(g, nohairpin=2, time_limit=20) if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: return None at_par = r["hist"].get(free[0], 0) cells = len(g.sand_cells()) if not (1 <= at_par <= max_at_par) or free[0] / cells > 0.45: return None return {"kind": "free", "garden": g, "cells": cells, "par": free[0], "rakings": r["nsol"], "at_par": at_par, "rarity": math.log(r["nsol"] / at_par), "target": list(reversed(grace[2]))} def cand_clued(g, rng, marks=False, max_clues=None): if not parity_ok(g) or g.solve(mode=0, cap=2)["nsol"] < 2: return None free = optimize(g, time_limit=30) grace = optimize(g, nohairpin=2, time_limit=30) if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: return None c = {"cells": len(g.sand_cells()), "target": list(reversed(grace[2]))} if free[0] / c["cells"] > 0.45: return None cl = (make_unique_marks if marks else make_unique)(g, c["target"], rng) if cl is None or len(cl) > (max_clues or (13 if marks else 9)): return None gu = (with_marks if marks else with_clues)(g, cl) gr = grade(gu) if not gr["solved"]: return None return {"kind": "strict", "garden": gu, "cells": c["cells"], "tier": gr["tier"], "trials": gr["trials"] or 0, "clues": len(cl), "target": c["target"]} def cand_wide(g, max_density=0.45): if not parity_ok(g) and g.end is None: return None g.nohairpin = 2 g.braced_start = True r = g.solve(mode=0, cap=2, stream=True) if r["nsol"] != 1: return None d = r["sols"][0]["draw"] cells = len(g.sand_cells()) if turns_of(g, d) / cells > max_density: return None gr = grade(g) if not gr["solved"]: return None return {"kind": "strict", "garden": g, "cells": cells, "tier": gr["tier"], "trials": gr["trials"] or 0, "target": d} def cand_side_marks(g, rng): """Side gate without the wide rake: pebbles from a graceful par raking until unique.""" r = g.solve(mode=0, cap=10**6) if r["nsol"] == 0 or not r["complete"]: return None free = optimize(g, time_limit=20) grace = optimize(g, nohairpin=2, time_limit=20) if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: return None target = list(reversed(grace[2])) cl = make_unique_marks(g, target, rng) if cl is None or len(cl) > 13: return None gu = with_marks(g, cl) gr = grade(gu) if not gr["solved"]: return None return {"kind": "strict", "garden": gu, "cells": len(g.sand_cells()), "tier": gr["tier"], "trials": gr["trials"] or 0, "clues": len(cl), "target": target} def make(spec, rng): kind, p = spec["gen"], spec H, W = p["H"], p["W"] feats = dict(ripple=p.get("ripple", False), band=p.get("band"), closed_ring=p.get("closed_ring", False)) if kind == "free": g = garden_layout(H, W, rng, **feats) if g is not None and p.get("ripple2"): from garden_sampler import add_ripple rows = [list(r) for r in g.rows] if not add_ripple(rows, rng, H, W): return None g = Garden(["".join(r) for r in rows], g.gate) return cand_free(g) if g is not None else None if kind in ("grooves", "pebbles"): g = garden_layout(H, W, rng, **feats) return cand_clued(g, rng, marks=(kind == "pebbles")) if g is not None else None if kind == "wide": g = garden_layout(H, W, rng, **feats) return cand_wide(g) if g is not None else None if kind == "side_wide": g = garden_layout(H, W, rng, **feats) if g is None or not add_side_gate(g, rng): return None return cand_wide(g) if kind == "side_pebbles": g = garden_layout(H, W, rng, **feats) if g is None or not add_side_gate(g, rng): return None return cand_side_marks(g, rng) raise ValueError(kind) CACHE = "/home/claude/zen/out/campaign_cache" def run_spec(args): spec, seed = args import os, pickle os.makedirs(CACHE, exist_ok=True) path = f"{CACHE}/{spec['id']}.pkl" if os.path.exists(path): return pickle.load(open(path, "rb")) res = _run_spec(spec, seed) pickle.dump(res, open(path, "wb")) return res def _run_spec(spec, seed): rng = random.Random(seed) t0, tried, found, seen = time.time(), 0, [], set() want = math.ceil(spec["n"] * spec.get("over", 2)) # generate extra, keep a subset spread over difficulty while len(found) < want and time.time() - t0 < spec.get("budget", 300): tried += 1 c = make(spec, rng) if c is None or key_of(c["garden"]) in seen: continue seen.add(key_of(c["garden"])) c["spec"] = spec["id"] found.append(c) return spec["id"], found, tried, time.time() - t0 # ---------------------------------------------------------------- the campaign SPECS = [ # world 1: free gardens with stones dict(id="w1-55", world=1, gen="free", H=5, W=5, n=2), dict(id="w1-56", world=1, gen="free", H=5, W=6, n=2), dict(id="w1-66", world=1, gen="free", H=6, W=6, n=3), dict(id="w1-67", world=1, gen="free", H=6, W=7, n=2), dict(id="w1-77", world=1, gen="free", H=7, W=7, n=2), dict(id="w1-78", world=1, gen="free", H=7, W=8, n=2), # world 2: ripple stones dict(id="w2-66", world=2, gen="free", H=6, W=6, ripple=True, n=3), dict(id="w2-67", world=2, gen="free", H=6, W=7, ripple=True, n=4), dict(id="w2-77", world=2, gen="free", H=7, W=7, ripple=True, n=4), dict(id="w2-78", world=2, gen="free", H=7, W=8, ripple=True, n=2), dict(id="w2-88", world=2, gen="free", H=8, W=8, ripple=True, ripple2=True, n=1, budget=600, role="combo", names="two_ripple"), dict(id="w2-77x2", world=2, gen="free", H=7, W=7, ripple=True, ripple2=True, n=2, budget=600, role="combo", names="two_ripple"), # world 3: wave bands (ichimatsu gardens are added by hand below) dict(id="w3-67", world=3, gen="free", H=6, W=7, band=True, n=4), dict(id="w3-77", world=3, gen="free", H=7, W=7, band=True, n=4), dict(id="w3-78", world=3, gen="free", H=7, W=8, band=True, n=3), dict(id="w3-88", world=3, gen="free", H=8, W=8, band=True, ripple=True, n=1, budget=600, role="combo", names="band_ripple"), dict(id="w3-77br", world=3, gen="free", H=7, W=7, band=True, ripple=True, n=1, budget=600, role="combo", names="band_ripple"), # world 4: grooves, closed rings, pebbles dict(id="w4-g66", world=4, gen="grooves", H=6, W=6, n=3), dict(id="w4-g77", world=4, gen="grooves", H=7, W=7, n=3), dict(id="w4-g78", world=4, gen="grooves", H=7, W=8, n=3), dict(id="w4-ring", world=4, gen="grooves", H=7, W=8, closed_ring=True, n=2, budget=600, over=1.5), dict(id="w4-p66", world=4, gen="pebbles", H=6, W=6, n=2), dict(id="w4-p77", world=4, gen="pebbles", H=7, W=7, n=1), dict(id="w4-p78", world=4, gen="pebbles", H=7, W=8, n=2), dict(id="w4-gr", world=4, gen="grooves", H=7, W=7, ripple=True, n=1, over=4, budget=600, role="combo", names="groove_ripple"), dict(id="w4-pb", world=4, gen="pebbles", H=7, W=7, band=True, n=1, budget=600, role="combo", names="pebble_band"), # world 5: the wide rake dict(id="w5-55", world=5, gen="wide", H=5, W=5, n=3), dict(id="w5-56", world=5, gen="wide", H=5, W=6, n=2), dict(id="w5-66", world=5, gen="wide", H=6, W=6, n=3), dict(id="w5-67", world=5, gen="wide", H=6, W=7, n=3), dict(id="w5-77", world=5, gen="wide", H=7, W=7, n=4), dict(id="w5-78", world=5, gen="wide", H=7, W=8, n=2), dict(id="w5-88", world=5, gen="wide", H=8, W=8, n=2), dict(id="w5-wr", world=5, gen="wide", H=7, W=7, ripple=True, n=1, budget=600, role="combo", names="wide_ripple"), dict(id="w5-wb", world=5, gen="wide", H=7, W=7, band=True, n=1, budget=600, role="combo", names="wide_band"), # world 6: the side gate dict(id="w6-67", world=6, gen="side_wide", H=6, W=7, n=3), dict(id="w6-77", world=6, gen="side_wide", H=7, W=7, n=4), dict(id="w6-78", world=6, gen="side_wide", H=7, W=8, n=3), dict(id="w6-88", world=6, gen="side_wide", H=8, W=8, n=1), dict(id="w6-p77", world=6, gen="side_pebbles", H=7, W=7, n=3, budget=600, role="combo"), # world 7: everything, larger dict(id="w7-wr", over=1.5, world=7, gen="wide", H=8, W=8, ripple=True, n=4, budget=900), dict(id="w7-wb", over=1.5, world=7, gen="wide", H=8, W=8, band=True, n=3, budget=900), dict(id="w7-prb", over=1.5, world=7, gen="pebbles", H=8, W=8, ripple=True, band=True, n=3, budget=900, role="combo"), dict(id="w7-swr", over=1.5, world=7, gen="side_wide", H=8, W=8, ripple=True, n=2, budget=900, role="combo"), dict(id="w7-ring", over=1.5, world=7, gen="wide", H=8, W=8, closed_ring=True, n=2, budget=900), dict(id="w7-w99", over=1.5, world=7, gen="wide", H=9, W=9, n=3, budget=900), dict(id="w7-p99", over=1.5, world=7, gen="pebbles", H=9, W=9, n=2, budget=900, fallback=dict(id="w7-p88", over=1.5, gen="pebbles", H=8, W=8, budget=900)), ] NAMES = { # names for gardens with no special feature, in order of play 1: ["Morning dew", "Mist", "First bell", "Sparrow", "Cold sand", "Lantern", "Bamboo", "The long wall", "Wind chime", "Pine shadow", "Heron's step", "Quiet", "Sunrise", "Kettle", "Swept path"], 2: ["Koi", "Bell's echo", "Turtle", "Stepping stone", "Pond edge", "Water lily", "Drip", "Carp", "Lotus", "Still water", "Well", "Spring", "Frog", "Mossy bank", "Moon in a bowl", "Rain on the roof"], 3: ["High tide", "Breakers", "Current", "Shallows", "Sandbar", "Reef", "Low tide", "Crosscurrent", "Undertow", "Swell", "Estuary", "Spring tide", "Noon bell"], 4: ["Afternoon", "Tea house"], 5: ["Pivot", "Hairpin", "Brace", "Long rake", "Turning stone", "Horseshoe", "Coil", "Shell", "Crescent", "Evening bell", "Swallow", "Bat", "Owl", "Ember", "Firefly", "Long shadow", "Last light", "Cicada", "Dragonfly", "Lanternlight", "Smoke"], 6: ["Two doors", "Visitor", "Garden path", "Through the wall", "Gatekeeper", "Postern", "Threshold", "Late arrival", "Crossing", "Wicket", "Hinge", "Latch", "Bolt", "Dusk", "Errand"], 7: ["Moonrise", "Starfield", "Lantern festival", "Night heron", "Owl's hour", "Milky Way", "Frost", "Moonlit sea", "Midnight", "Deep night", "Night rain", "Dark water", "Stargazer", "The abbot's garden", "Moonbeam", "Moon pool", "Last bell", "Small hours", "Before dawn", "Sleeping temple"], } POOLS = { # names that describe a feature, used only for gardens that have it "two_ripple": ["Twin rings", "Two moons", "Paired wells"], "band_ripple": ["Noon sun", "Island in the current"], "ichimatsu": ["Tatami", "Weave", "Lattice", "Checkered shade"], "groove": ["His first lines", "Unfinished", "Interrupted", "Called away", "Old grooves", "Continuation", "Halfway", "Left for you", "Plain speaking", "Margin notes"], "ring": ["The abbot's ring", "Closed circle"], "pebble": ["Pale and dark", "White stones", "Black stones", "Go board", "Counting stones", "Afternoon tea"], "groove_ripple": ["Ripple and groove"], "pebble_band": ["Pebbles in the current"], "wide_ripple": ["Whorl"], "wide_band": ["Wake"], } def search_two_tones(rng, budget=120): """World 1's parity lesson: a small garden where the obvious place to end (the cell farthest from the gate) is the wrong colour, so every walk that ends there strands a cell.""" t0 = time.time() best = None while time.time() - t0 < budget: H, W = rng.choice([(4, 5), (5, 5), (5, 4)]) g = garden_layout(H, W, rng, n_pieces=1, max_frac=0.1) if g is None or not parity_ok(g): continue r = g.solve(mode=0, cap=10**6, stream=True) if not (2 <= r["nsol"] <= 40): continue # BFS distance from the gate from collections import deque start = (g.gate[0], g.gate[1]); dist = {start: 0}; q = deque([start]) while q: a = q.popleft() for d in ((1, 0), (-1, 0), (0, 1), (0, -1)): b = (a[0] + d[0], a[1] + d[1]) if g.is_sand(*b) and b not in dist: dist[b] = dist[a] + 1; q.append(b) far = max(dist, key=lambda c: (dist[c], c in ((0, 0), (0, W - 1), (H - 1, 0), (H - 1, W - 1)))) ends = {tuple(s["draw"][-1]) for s in r["sols"]} if far in ends or far[0] not in (0, H - 1) or far[1] not in (0, W - 1): continue c = cand_free(g, max_at_par=10**9, min_rakings=2) if c is None: continue score = (len(ends) <= 3, -r["nsol"]) if best is None or score > best[0]: best = (score, c, far, sorted(ends)) return best def search_small(gen, H, W, rng, budget=120, side=False, ripple=False, max_tier=3): t0 = time.time() best = None while time.time() - t0 < budget: if ripple: g = garden_layout(H, W, rng, ripple=True, n_pieces=1) else: g = garden_layout(H, W, rng, n_pieces=1, max_frac=0.1) if g is None: continue if side and not add_side_gate(g, rng): continue c = cand_free(g) if gen == "free" else cand_wide(g) if c is None or (gen != "free" and c["tier"] > max_tier): continue score = (-c.get("tier", 0), c.get("rarity", 0)) if best is None or score > best[0]: best = (score, c) return best[1] if best else None def hand(name, world, g, intro=None, hint="auto", tone=0.0, kind=None, cells=None): return {"name": name, "world": world, "garden": to_dict(g), "intro": intro, "hint": hint, "tone": tone, "kind": kind, "hint_cells": cells} def main(): t_start = time.time() rng = random.Random(2026) stats = {} with Pool(2) as pool: jobs = [(s, 1000 + i) for i, s in enumerate(SPECS)] results = [] for res in pool.imap_unordered(run_spec, jobs, chunksize=1): results.append(res) print(f" done {res[0]} ({len(results)}/{len(jobs)})", flush=True) by_spec = {} for sid, found, tried, secs in results: by_spec[sid] = found stats[sid] = {"tried": tried, "found": len(found), "seconds": round(secs)} print(f"{sid:8s} tried {tried:6d} found {len(found):3d} in {secs:5.0f}s", flush=True) # a spec that found too few falls back to an easier one in the same slot (recorded in the stats) for i, s in enumerate(SPECS): if len(by_spec[s["id"]]) < s["n"] and "fallback" in s: fb = dict(s["fallback"], world=s["world"], n=s["n"]) sid, found, tried, secs = run_spec((fb, 5000 + i)) print(f"{s['id']} found {len(by_spec[s['id']])} < {s['n']}: fallback {sid} found {len(found)} in {secs:.0f}s", flush=True) stats[sid] = {"tried": tried, "found": len(found), "seconds": round(secs), "fallback_for": s["id"]} by_spec[s["id"]] = found + by_spec[s["id"]] # hand-made and searched teaching gardens (the searches are cached too) import os, pickle tpath = f"{CACHE}/teaching.pkl" if os.path.exists(tpath): two, wide_intro, side_intro, ripple_intro = pickle.load(open(tpath, "rb")) else: two = search_two_tones(rng) wide_intro = search_small("wide", 5, 5, rng) side_intro = search_small("wide", 6, 6, rng, side=True) ripple_intro = search_small("free", 5, 6, rng, ripple=True) pickle.dump((two, wide_intro, side_intro, ripple_intro), open(tpath, "wb")) if not (two and wide_intro and side_intro and ripple_intro): sys.exit(f"teaching search failed: {bool(two)}, {bool(wide_intro)}, {bool(side_intro)}, {bool(ripple_intro)}") stats["two_tones"] = {"far_cell": two[2], "ends": two[3]} # contrasting pairs that introduce each rule: the same small garden, before and after one change (pairs.py) ppath = f"{CACHE}/pairs.pkl" if os.path.exists(ppath): PAIRS = pickle.load(open(ppath, "rb")) else: import pairs as pairmod with Pool(2) as pool: PAIRS = dict(pool.imap_unordered(pairmod.run, [(nm, 7000 + i, None) for i, nm in enumerate(pairmod.MAKERS)])) pickle.dump(PAIRS, open(ppath, "wb")) if any(v is None for v in PAIRS.values()): sys.exit(f"pair search failed: {[k for k, v in PAIRS.items() if v is None]}") stats["pairs"] = {k: dict(v["facts"], tried=v["tried"], size=[v["B"].H, v["B"].W]) for k, v in PAIRS.items()} # a searched teaching garden can coincide with a generated candidate of the same size: keep it for teaching only reserved = {key_of(c["garden"]) for c in (two[1], wide_intro, side_intro, ripple_intro)} for sid in by_spec: dropped = [c for c in by_spec[sid] if key_of(c["garden"]) in reserved] if dropped: print(f"{sid}: {len(dropped)} candidate(s) kept back as teaching gardens", flush=True) by_spec[sid] = [c for c in by_spec[sid] if key_of(c["garden"]) not in reserved] w = json.load(open("/home/claude/zen/out/wide_pick.json")) t = json.load(open("/home/claude/zen/out/two_pick.json")) wide_list = json.load(open("/home/claude/zen/out/search3_wide.json")) gw = next(from_dict(d) for dens, trials, k, d, draw in wide_list if d["rows"] == ['........', '........', '..#.....', '....#...', '........', '........', '......#.'] and d["gate"] == [6, 5, "S"]) mp = json.load(open("/home/claude/zen/out/marks_pick.json")) # layouts already in the campaign, up to symmetry: a generated garden may be a mirror image of a teaching garden, # and the same stones and gate can pass the filters of two worlds (free, and wide) teaching = [Garden(["....", "###.", "...."], (2, 0, "S")), Garden(["...", "..."], (1, 0, "S")), Garden(["...", "...", "..."], (2, 0, "S")), Garden(["#....", "....#", ".....", "....."], (3, 0, "S")), two[1]["garden"], wide_intro["garden"], side_intro["garden"], ripple_intro["garden"], gw, from_dict(w["garden"]), from_dict(t["garden"]), from_dict(mp["garden"])] + \ [from_dict(L[k]["garden"]) for k in L] + [v[x] for v in PAIRS.values() for x in ("A", "B") if x in v] used = {canon(x) for x in teaching} def pick(sid, n, order): cs, ks = [], set() for c in sorted(by_spec[sid], key=order): # also drop mirror images among the spec's own candidates k = canon(c["garden"]) if k not in used and k not in ks: cs.append(c); ks.add(k) if len(cs) < len(by_spec[sid]): print(f"{sid}: {len(by_spec[sid]) - len(cs)} candidate(s) skipped, their layout (up to symmetry) already used", flush=True) if len(cs) < n: sys.exit(f"spec {sid}: only {len(cs)} of {n}") # spread over the difficulty range rather than taking the easiest n idx = [round(i * (len(cs) - 1) / max(1, n - 1)) for i in range(n)] if n > 1 else [len(cs) // 2] out = [cs[i] for i in sorted(set(idx))][:n] if len(set(idx)) == n else cs[:n] used.update(canon(c["garden"]) for c in out) return out free_order = lambda c: (c["cells"], c["rarity"]) strict_order = lambda c: (c["tier"], c["trials"], c["cells"]) def gen_entries(world, sids, order): out = [] for s in SPECS: if s["world"] == world and s["id"] in sids: out += pick(s["id"], s["n"], order) return sorted(out, key=order) # ---------------------------------------------------------------- order each world by the template # intro; applications (easy to hard); a trap (the garden that most punishes the obvious first move, walking straight # in from the gate); combinations with earlier rules; a calm, easy garden; a capstone. Worlds that introduce a second # mechanic (ichimatsu; closed rings and pebbles) give it its own short section, after the trap. from hints import trap_score, near_misses def profile(c): """Ordering data: difficulty, turn density (calmness) and trap score, from the solvers.""" g = c["garden"] if not isinstance(c["garden"], dict) else from_dict(c["garden"]) if c["kind"] == "free": P = [s_["draw"] for s_ in g.solve(mode=2, bound=c["par"], cap=10**6, stream=True)["sols"]] c["trap"] = trap_score(g, c["par"], P, near_misses(g, c["par"])) c["density"] = c["par"] / c["cells"] c["diff"] = (c["cells"], c["rarity"]) else: c["trap"] = trap_score(g, None, draw=c["target"]) c["density"] = turns_of(g, c["target"]) / c["cells"] c["diff"] = (c["tier"], c["trials"], c["cells"]) return c def hand_profile(e, kind): g = from_dict(e["garden"]) if kind == "free": r = g.solve(mode=0, cap=10**8) free = optimize(g, time_limit=60) e.update(kind="free", par=free[0], cells=len(g.sand_cells()), rarity=math.log(r["nsol"] / r["hist"][free[0]])) else: gr = grade(g) d = g.solve(mode=0, cap=2, stream=True)["sols"][0]["draw"] e.update(kind="strict", tier=gr["tier"], trials=gr["trials"] or 0, cells=len(g.sand_cells()), target=d) return profile(e) def spread(n, k): """k indices spread over the easier part of range(n): the easiest, then evenly spaced short of the hardest (the capstone is the world's hard garden; the hardest applications are extras).""" if k >= n: return list(range(n)) return [round(i * (n - 1) / k) for i in range(k)] def arrange(apps, combos=(), capstone=None, sections=(), n_apps=2, n_combos=2, showpiece=None): """The template after a world's introduction: two applications (easy to hard, then a hand-made showpiece if the world has one), the trap, any mid-world sections, two combinations, the calm garden and the capstone form the main path; the other applications and combinations are optional extras. Returns (main, extras), with roles recorded.""" apps, combos = list(apps), list(combos) if capstone is None: # the hardest garden closes the world capstone = max(apps + combos, key=lambda c: c["diff"]) (apps if capstone in apps else combos).remove(capstone) apps.sort(key=lambda c: c["diff"]) trap = None cands = [c for c in apps if c["trap"] > 0] if cands: # the strongest trap; ties: nearest the middle difficulty trap = max(cands, key=lambda c: (round(c["trap"], 2), -abs(apps.index(c) - len(apps) // 2))) apps.remove(trap) easy = apps[: max(1, len(apps) // 2)] # a calm garden: easy, and with the fewest turns per cell calm = min(easy, key=lambda c: c["density"]) apps.remove(calm) main_apps = [apps[i] for i in spread(len(apps), n_apps)] combos.sort(key=lambda c: c["diff"]) main = main_apps + ([showpiece] if showpiece else []) + ([trap] if trap else []) extras = [c for c in apps if c not in main_apps] for sec_main, sec_extra in sections: # (main part, extra part) of a rule introduced mid-world main += sec_main; extras += sec_extra main += combos[:n_combos] + [calm, capstone] extras += combos[n_combos:] for c in main + extras: c.setdefault("role", "application") if trap: trap["role"] = "trap" calm["role"], capstone["role"] = "calm", "capstone" for c in combos: c["role"] = "combination" for c in extras: c["extra"] = True return main, extras def gen(world, combo=False, only=None): out = [] for s_ in SPECS: if s_["world"] == world and (s_.get("role") == "combo") == combo and (only is None or s_["id"] in only): for c in pick(s_["id"], s_["n"], lambda c: (c["cells"], c["rarity"]) if c["kind"] == "free" else (c["tier"], c["trials"], c["cells"])): out.append(profile(dict(c, world=world, name=None, pool=s_.get("names")))) return out def pair_entry(kind, which, world, name, intro, role): return dict(hand(name, world, PAIRS[kind][which], intro), role=role, pair=kind) SAME = "The next garden is this one again, with one change." W, X = {}, {} # each world's main path, and its optional extras # ---- world 1: the teaching gardens, then the template's tail (no combinations: there is nothing earlier). The first # real garden comes third, before the two technique lessons (dead ends, parity), which only matter at its size: an # early player did not "sense the potential until about six levels in", when it came sixth. (A 2x3 garden that taught # the U-turn was dropped: the 3x3 garden's par rakings make U-turns anyway.) fs = hand_profile(hand("First stroke", 1, from_dict(L["first"]["garden"]), None, "Both par rakings begin by walking straight up from the gate, and end near the top-left corner."), "free") calm1 = hand("Calm", 1, from_dict(L["calm"]["garden"]), None, "At par, the walk ends right beside the gate.") m, X[1] = arrange(gen(1) + [fs], capstone=calm1) teach = [hand("The corridor", 1, Garden(["....", "###.", "...."], (2, 0, "S")), "Drag the monk in from the gate, covering every cell of sand in one stroke; when you let go, he rakes his way back out.", "There is only one way through: follow the corridor."), hand("Nine squares", 1, Garden(["...", "...", "..."], (2, 0, "S")), "Fewer turns is calmer. Match par for the abbot's seal.", "Fewer turns mean longer lines: run each one from wall to wall."), hand("The pocket", 1, Garden(["#....", "....#", ".....", "....."], (3, 0, "S")), "A cell with only one way in is where the walk must end: the rake is set down there.", "The top-right corner has only one way in, so the walk has to end there.", cells=[[0, 4]]), hand("Two tones", 1, two[1]["garden"], "Dark and light: every step changes colour, so a stroke ends on the colour there is more of.", "auto-parity", tone=1.0)] for e in teach: e["role"] = "intro" W[1] = teach[:2] + m[:1] + teach[2:] + m[1:] W[1][5]["tone"], W[1][6]["tone"] = 0.6, 0.3 # the two tones fade over the next gardens # ---- world 2: ripple stones, introduced by a pair; combinations have two ripple stones W[2] = [pair_entry("ripple", "A", 2, "Plain stone", f"Rake this one as usual. {SAME}", "pair"), pair_entry("ripple", "B", 2, "First ripple", "The same garden, with a ripple stone: its eight neighbours must be raked as one unbroken ring.", "intro")] ripples = hand("Ripples", 2, from_dict(L["ripple"]["garden"]), None, "Rake the ring in one run; at par, the rest of the garden wraps around it in long lines.") m, X[2] = arrange(gen(2), gen(2, combo=True), showpiece=ripples) W[2] += m X[2] = [dict(ripple_intro, name=None, world=2)] + X[2] # ---- world 3: wave bands, introduced by a pair; then ichimatsu, its own short section; combinations add a ripple stone ich = [] # ichimatsu variants: only 3x3 blocks can be raked at all (2x2 blocks have no valid raking at any gate), and a 6x6 # garden with its blocks flipped is a mirror image of the original, as a 9x6 one is of a 6x9; so the variants move # the gate off the corner and widen the garden (each has one raking; all are distinct up to symmetry) for nm, (nbr, nbc, b, gc, flip) in (("ichimatsu-gate2", (2, 2, 3, 2, False)), ("ichimatsu-6x9", (2, 3, 3, 0, False)), ("ichimatsu-6x9-gate2", (2, 3, 3, 2, False))): gi = ichimatsu(nbr, nbc, b, gc, flip) r = gi.solve(mode=0, cap=50) if not (1 <= r["nsol"] <= 6 and parity_ok(gi)): sys.exit(f"ichimatsu variant {nm}: {r['nsol']} rakings") ich.append((r["nsol"], nm, gi)) ich.sort(key=lambda x: (len(x[2].sand_cells()), x[0])) stats["ichimatsu_variants"] = [(n, nm, x.rows, list(x.gate)) for n, nm, x in ich] ich_entries = [dict(hand(None, 3, x, None, "auto"), role="application", pool="ichimatsu") for n, nm, x in ich] ich_section = ([dict(hand("Ichimatsu", 3, Garden(["===|||"] * 3 + ["|||==="] * 3, (5, 0, "S")), "Checkerboard blocks: rake each block across or down, as hatched.", "Rake each block as three parallel lines, then step into the next block."), role="intro"), ich_entries[0]], ich_entries[1:]) W[3] = [pair_entry("band", "A", 3, "Calm water", f"Rake this one as usual. {SAME}", "pair"), pair_entry("band", "B", 3, "First wave", "The same garden, with a wave band: each row of the hatched band is crossed in one straight run, edge to edge.", "intro")] waves = hand("Waves", 3, from_dict(L["waves"]["garden"]), None, "Each row of the band is one straight run, so every turn happens in the plain sand at its ends.") m, X[3] = arrange(gen(3), gen(3, combo=True), sections=[ich_section], showpiece=waves) W[3] += m # ---- world 4: grooves, introduced by a pair; then the finished ring and the pebbles, each its own short section rings = sorted(pick("w4-ring", 2, lambda c: (c["tier"], c["trials"], c["cells"])), key=lambda c: (c["tier"], c["trials"], c["cells"])) for c_ in rings: c_.update(name=None, world=4, pool="ring", role="application") ring_section = ([dict(hand("The finished ring", 4, PAIRS["ring"]["B"], "A ring the abbot has already raked around a stone: leave it as it is, and rake around it."), role="intro", pair="ring"), rings[0]], [rings[1]]) pebbles = sorted(gen(4, only={"w4-p66", "w4-p77", "w4-p78"}), key=lambda c: c["diff"]) for c_ in pebbles: c_.update(pool="pebble", role="application") pebble_section = ([pair_entry("pebbles", "A", 4, "His grooves", f"Another garden with the abbot's grooves. {SAME}", "pair"), pair_entry("pebbles", "B", 4, "His pebbles", "The same garden, with pebbles where the grooves were. Pale pebble: the line runs straight through it, either way. " "Dark pebble: the line turns at it, either way.", "intro"), pebbles[0], hand("The abbot's pebbles", 4, from_dict(mp["garden"]), None, "A pebble beside a wall or a stone often leaves only one way through; settle those first.")], pebbles[1:]) W[4] = [pair_entry("grooves", "A", 4, "Open ground", f"Many rakings fit this garden. {SAME}", "pair"), pair_entry("grooves", "B", 4, "First grooves", "The same garden: the abbot began the raking. Your stroke must run along his grooves, and now only one raking fits.", "intro")] masters = hand("The master's grooves", 4, from_dict(L["grooves"]["garden"]), None, "A groove fixes how the stroke enters and leaves its cell, and often its neighbours too: extend each one first.") grooves = gen(4, only={"w4-g66", "w4-g77", "w4-g78"}) for c_ in grooves: c_["pool"] = "groove" m, X[4] = arrange(grooves, gen(4, combo=True), sections=[ring_section, pebble_section], showpiece=masters) W[4] += m # ---- world 5: the wide rake, introduced by a pair whose every par raking turns back in open sand; Spiral closes it wide_rake = hand_profile(hand("Wide rake", 5, gw, None, "Every reversal needs a pivot: each U-turn must hug a wall or wrap around a stone."), "strict") spiral = hand("Spiral", 5, from_dict(w["garden"]), None, "One stone stands in open sand: the only way to turn back around it is to spiral in toward it.") W[5] = [pair_entry("wide", "A", 5, "Narrow rake", f"Rake this one with the ordinary rake. {SAME}", "pair"), pair_entry("wide", "B", 5, "Wide blade", "The same garden, with the wide rake: it cannot turn back in open sand, since a U-turn needs a wall " "or a stone to pivot on; and it is set down against one.", "intro")] m, X[5] = arrange(gen(5) + [wide_rake], gen(5, combo=True), capstone=spiral) W[5] += m X[5] = [dict(wide_intro, name=None, world=5)] + X[5] # ---- world 6: the side gate, introduced by a pair; combinations add pebbles; Side gate closes it sg = hand("Side gate", 6, from_dict(t["garden"]), None, "Both ends are fixed: plan backwards from the side gate as well as forwards from the veranda.") W[6] = [pair_entry("side", "A", 6, "One gate", f"A garden for the wide rake. {SAME}", "pair"), pair_entry("side", "B", 6, "Two gates", "The same garden, with a side gate: the stroke must begin there, and end at the veranda. " "Of the first garden's rakings, only one begins there.", "intro")] m, X[6] = arrange(gen(6), gen(6, combo=True), capstone=sg) W[6] += m X[6] = [dict(side_intro, name=None, world=6)] + X[6] # ---- world 7: everything, larger; three-rule gardens are the combinations; Silver sand closes the game silver = hand("Silver sand", 7, Garden(["".join("=" if ((r // 3) + (c // 3)) % 2 == 0 else "|" for c in range(9)) for r in range(9)], (8, 0, "S")), None, "Nine blocks, each raked as three parallel lines: find the order in which each block hands the stroke to the next.") apps7 = sorted(gen(7), key=lambda c: c["diff"]) first7 = dict(apps7.pop(0), role="intro", intro="Night: everything the day taught, in larger gardens.") m, X[7] = arrange(apps7, gen(7, combo=True), capstone=silver) W[7] = [first7] + m for wld in X: for e in X[wld]: e["extra"] = True e.setdefault("role", "application") W = {wld: W[wld] + X[wld] for wld in W} for wld in W: for e in W[wld]: e.setdefault("role", "application") # names, and the final list: a garden keeps the name its layout had before (out/garden_names.json, seeded by # seed_garden_names.py), so that names, poems and saved progress survive rearranging the campaign; a garden new to the # campaign takes the next name of its pool that no other layout owns def full_key(d): return json.dumps([d["rows"], d["gate"], d["end"], d.get("end_side"), d["nohairpin"], bool(d["braced_start"]), sorted(d["clues"].items()), sorted((d.get("marks") or {}).items())]) REG = {r["key"]: r["name"] for r in json.load(open(NAMES_REG))} if os.path.exists(NAMES_REG) else {} owned = set(REG.values()) final = [] pools = {k: iter(v) for k, v in POOLS.items()} for world in range(1, 8): names = iter(NAMES[world]) taken = {e.get("name") for e in W[world] if e.get("name")} for e in W[world]: if "garden" in e and not isinstance(e["garden"], dict): e["garden"] = to_dict(e["garden"]) if not e.get("name"): nm = REG.get(full_key(e["garden"])) if nm is None or nm in taken: src = pools[e["pool"]] if e.get("pool") else names nm = next(src) while nm in taken or nm in owned: nm = next(src) e["name"] = nm taken.add(nm) final.append({"name": e["name"], "world": world, "garden": e["garden"], "intro": e.get("intro") if not e.get("extra") else None, "hint": e.get("hint", "auto"), "tone": e.get("tone", 0.0), "role": e["role"], "hint_cells": e.get("hint_cells"), "trap": round(e.get("trap", 0.0), 3), "extra": bool(e.get("extra")), "pair": e.get("pair")}) stats[f"world{world}"] = len(W[world]) print(world, " | ".join(f"{e['name']} ({e['role'][:4]}{', x' if e.get('extra') else ''})" for e in W[world]), flush=True) counts = [stats[f"world{w}"] for w in range(1, 8)] print("gardens per world:", counts, "total", sum(counts)) stats["main"] = [sum(not e.get("extra") for e in W[w]) for w in range(1, 8)] stats["extras"] = [sum(bool(e.get("extra")) for e in W[w]) for w in range(1, 8)] print("main path per world:", stats["main"], "extras:", stats["extras"]) # the whole definition: a contrasting pair is one layout twice, differing only in its clues, pebbles or rake def full_key(d): return (tuple(d["rows"]), tuple(d["gate"]), tuple(d["end"] or ()), d.get("end_side"), d["nohairpin"], bool(d["braced_start"]), tuple(sorted(d["clues"].items())), tuple(sorted((d.get("marks") or {}).items()))) seen_k = {} for e in final: k = full_key(e["garden"]) if k in seen_k: print("duplicate:", seen_k[k], "=", e["name"], flush=True) seen_k[k] = e["name"] assert len({full_key(e["garden"]) for e in final}) == len(final), "duplicate gardens" same = [e["name"] for e in final if e.get("pair") and e["role"] == "pair"] stats["layouts"] = len({canon(from_dict(e["garden"])) for e in final}) json.dump(final, open(OUT, "w")) stats["seconds"] = round(time.time() - t_start) json.dump(stats, open(STATS, "w"), indent=1) print("saved", OUT, f"{stats['seconds']}s") if __name__ == "__main__": main() src/clues.py — Greedy 'master's grooves' clue selection: add target-solution glyphs until the garden has a unique raking. """Greedy 'master's grooves' clue selection: add target-solution glyphs until the garden has a unique raking.""" import random from copy import deepcopy from zg import Garden, glyphs def with_clues(g, clues): g2 = deepcopy(g) g2.clues = dict(g.clues); g2.clues.update(clues) return g2 def count(g, cap): r = g.solve(mode=0, cap=cap, nodelimit=2 * 10**8) return r["nsol"], r def make_unique(g, target_draw, rng, cap=3000, max_clues=40, sample=24): gl = glyphs(g, target_draw) start = target_draw[-1] cand_all = [c for c in gl if c != start and len(gl[c]) == 2 and c not in g.clues] clues = {} n, _ = count(g, cap) while n > 1 and len(clues) < max_clues: cand = [c for c in cand_all if c not in clues] rng.shuffle(cand) best, bestn = None, None for c in cand[:sample]: m, _ = count(with_clues(g, {**clues, c: gl[c]}), cap) if bestn is None or m < bestn: best, bestn = c, m if m == 1: break clues[best] = gl[best] n = bestn if n != 1: return None # prune redundant clues for c in list(clues): trial = {k: v for k, v in clues.items() if k != c} m, _ = count(with_clues(g, trial), 2) if m == 1: clues = trial return clues def with_marks(g, marks): g2 = deepcopy(g) g2.marks = dict(g.marks); g2.marks.update(marks) return g2 def make_unique_marks(g, target_draw, rng, cap=3000, max_marks=60, sample=24): """Like make_unique, but with the abbot's *marks*: each says only 'straight here' ('S') or 'turn here' ('B'), taken from the target raking; returns None if marks alone cannot make the target unique.""" gl = glyphs(g, target_draw) start = target_draw[-1] kind = {c: ("S" if set(ds) in ({"E", "W"}, {"N", "S"}) else "B") for c, ds in gl.items() if len(ds) == 2} cand_all = [c for c in kind if c != start and c not in g.clues and c not in g.marks] marks = {} n, _ = count(g, cap) while n > 1 and len(marks) < max_marks: cand = [c for c in cand_all if c not in marks] if not cand: break rng.shuffle(cand) best, bestn = None, None for c in cand[:sample]: m, _ = count(with_marks(g, {**marks, c: kind[c]}), cap) if bestn is None or m < bestn: best, bestn = c, m if m == 1: break marks[best] = kind[best] n = bestn if n != 1: return None for c in list(marks): trial = {k: v for k, v in marks.items() if k != c} m, _ = count(with_marks(g, trial), 2) if m == 1: marks = trial return marks src/daily.py — A year of daily gardens (366), the same for everyone on a given date, none of them in the campaign. """daily.py: a year of daily gardens (366), the same for everyone on a given date, none of them in the campaign. Free and strict gardens alternate (free: scored against par; strict: exactly one raking, under grooves, pebbles, the wide rake or a side gate), sizes 5x5 to 8x8, made by the campaign's own generators (campaign.make) and filters. The candidates the campaign generated and did not use are taken first; the rest are generated here (cached in out/daily_cache/). Each garden is exported as the prototype expects (export_proto.py's fields: par, raking counts, every par raking as move strings for the in-page hints, an intended raking, a first observation). Writes out/daily_gardens.json (day 0 = 2026-01-01; the page picks day-of-cycle from the local date) and out/daily_stats.json. """ from __future__ import annotations import glob import json import math import os import pickle import random import sys import time from multiprocessing import Pool from zg import turns_of, check_solution from zcp import optimize from levels import from_dict, to_dict from campaign import canon, key_of, _run_spec, CACHE from hints import best_free_hint, best_strict_hint, near_misses OUT = "/home/claude/zen/out/daily_gardens.json" STATS = "/home/claude/zen/out/daily_stats.json" DCACHE = "/home/claude/zen/out/daily_cache" YEAR = 366 MAX_AT_PAR = 24 # free gardens with more par rakings than this are left out (the page carries every par raking) MOVE = {(-1, 0): "N", (1, 0): "S", (0, 1): "E", (0, -1): "W"} def moves_of(d): return "".join(MOVE[(b[0] - a[0], b[1] - a[1])] for a, b in zip(d, d[1:])) FREE = [ dict(id="d-f66", gen="free", H=6, W=6, n=18), dict(id="d-f67", gen="free", H=6, W=7, n=20), dict(id="d-f77", gen="free", H=7, W=7, n=22), dict(id="d-f78", gen="free", H=7, W=8, n=12), dict(id="d-r66", gen="free", H=6, W=6, ripple=True, n=14), dict(id="d-r67", gen="free", H=6, W=7, ripple=True, n=18), dict(id="d-r77", gen="free", H=7, W=7, ripple=True, n=20), dict(id="d-r78", gen="free", H=7, W=8, ripple=True, n=10), dict(id="d-r77x2", gen="free", H=7, W=7, ripple=True, ripple2=True, n=8, budget=400), dict(id="d-b67", gen="free", H=6, W=7, band=True, n=14), dict(id="d-b77", gen="free", H=7, W=7, band=True, n=18), dict(id="d-b78", gen="free", H=7, W=8, band=True, n=10), dict(id="d-br77", gen="free", H=7, W=7, band=True, ripple=True, n=8, budget=400), dict(id="d-f66b", gen="free", H=6, W=6, n=8), dict(id="d-r67b", gen="free", H=6, W=7, ripple=True, n=8), # (the first round fell 4 short after the par-raking cap) ] STRICT = [ dict(id="d-g66", gen="grooves", H=6, W=6, n=16), dict(id="d-g77", gen="grooves", H=7, W=7, n=20), dict(id="d-g78", gen="grooves", H=7, W=8, n=8, budget=400), dict(id="d-gr77", gen="grooves", H=7, W=7, ripple=True, n=6, budget=400), dict(id="d-ring78", gen="grooves", H=7, W=8, closed_ring=True, n=6, budget=400), dict(id="d-p66", gen="pebbles", H=6, W=6, n=12), dict(id="d-p77", gen="pebbles", H=7, W=7, n=12), dict(id="d-p78", gen="pebbles", H=7, W=8, n=4, budget=300), dict(id="d-pb77", gen="pebbles", H=7, W=7, band=True, n=4, budget=300), dict(id="d-w55", gen="wide", H=5, W=5, n=8), dict(id="d-w56", gen="wide", H=5, W=6, n=8), dict(id="d-w66", gen="wide", H=6, W=6, n=14), dict(id="d-w67", gen="wide", H=6, W=7, n=14), dict(id="d-w77", gen="wide", H=7, W=7, n=16), dict(id="d-w78", gen="wide", H=7, W=8, n=10), dict(id="d-w88", gen="wide", H=8, W=8, n=8), dict(id="d-wr77", gen="wide", H=7, W=7, ripple=True, n=8), dict(id="d-wb77", gen="wide", H=7, W=7, band=True, n=8), dict(id="d-s67", gen="side_wide", H=6, W=7, n=12), dict(id="d-s77", gen="side_wide", H=7, W=7, n=14), dict(id="d-s78", gen="side_wide", H=7, W=8, n=10), dict(id="d-s88", gen="side_wide", H=8, W=8, n=6), dict(id="d-sp77", gen="side_pebbles", H=7, W=7, n=6, budget=300), ] for s in FREE + STRICT: s.setdefault("over", 1) s.setdefault("budget", 240) def run_cached(args): spec, seed = args os.makedirs(DCACHE, exist_ok=True) path = f"{DCACHE}/{spec['id']}.pkl" if os.path.exists(path): return pickle.load(open(path, "rb")) res = _run_spec(spec, seed) pickle.dump(res, open(path, "wb")) return res def leftovers(used): """Candidates the campaign generated and did not use (by layout up to symmetry).""" out = [] for f in sorted(glob.glob(f"{CACHE}/w*.pkl")): sid, found, tried, secs = pickle.load(open(f, "rb")) for c in found: if canon(c["garden"]) not in used and c["garden"].H <= 8 and c["garden"].W <= 8: # (the daily garden stays at 8x8 or smaller) c = dict(c) c["spec"] = "campaign:" + sid out.append(c) return out EXPORTS = f"{DCACHE}/exports.pkl" def export(c, k): """The prototype's garden record for candidate c (see export_proto.add), as day k; cached by layout.""" cache = pickle.load(open(EXPORTS, "rb")) if os.path.exists(EXPORTS) else {} ck = (key_of(c["garden"]), tuple(sorted(to_dict(c["garden"])["clues"].items())), tuple(sorted(to_dict(c["garden"])["marks"].items())), c["garden"].nohairpin) if ck in cache: return cache[ck] e = _export(c, k) cache[ck] = e pickle.dump(cache, open(EXPORTS, "wb")) return e def _export(c, k): g = c["garden"] intended = [tuple(x) for x in c["target"]] assert check_solution(g, intended), c["spec"] r = g.solve(mode=0, cap=10**9, nodelimit=4 * 10**9) assert r["nsol"] > 0 and r["complete"] and not r["capped"], c["spec"] if c["kind"] == "free": free = optimize(g, time_limit=120) assert free[1] == "OPTIMAL", c["spec"] par = free[0] else: assert r["nsol"] == 1, c["spec"] par = turns_of(g, intended) assert turns_of(g, intended) == par, c["spec"] at_par = r["hist"].get(par, 0) assert at_par > 0, c["spec"] if at_par > MAX_AT_PAR: return None rp = g.solve(mode=2, bound=par, cap=10**6, stream=True) par_rakings = [s_["draw"] for s_ in rp["sols"]] assert rp["complete"] and not rp["capped"] and len(par_rakings) == rp["nsol"] == at_par, c["spec"] assert all(check_solution(g, d_) and turns_of(g, d_) == par for d_ in par_rakings), c["spec"] par_rakings.sort(key=lambda d_: [tuple(x) for x in d_] != intended) assert [tuple(x) for x in par_rakings[0]] == intended, c["spec"] if r["nsol"] == 1: hint, hint_cells, _ = best_strict_hint(g, intended) else: hint, hint_cells, _ = best_free_hint(g, par, par_rakings, near_misses(g, par)) d = to_dict(g) return {"name": f"daily-{k:03d}", "world": 0, "rows": g.rows, "gate": list(g.gate), "end": list(g.end) if g.end else None, "end_side": g.end_side, "clues": d["clues"], "wide": g.nohairpin == 2, "par": par, "rakings": r["nsol"], "at_par": at_par, "intended": [list(x) for x in intended], "intro": None, "marks": d["marks"], "par_moves": [moves_of(d_) for d_ in par_rakings], "hint": hint, "tone": 0.0, "hint_cells": hint_cells or [], "role": "daily", "trap": 0.0, "extra": False, "pair": None, "demo": False, "kind": c["kind"], "spec": c["spec"]} def main(): t0 = time.time() C = json.load(open("/home/claude/zen/out/campaign.json")) used = {canon(from_dict(e["garden"])) for e in C} cands = leftovers(used) print(f"{len(cands)} candidates left over from the campaign", flush=True) specs = FREE + STRICT with Pool(2) as pool: results = pool.map(run_cached, [(s, 9000 + i) for i, s in enumerate(specs)]) stats = {} for sid, found, tried, secs in results: stats[sid] = {"tried": tried, "found": len(found), "seconds": round(secs)} print(f"{sid:9s} tried {tried:6d} found {len(found):3d} in {secs:5.0f}s", flush=True) cands += found # one of each layout (up to symmetry), none from the campaign seen, uniq = set(used), [] for c in cands: cn = canon(c["garden"]) if cn in seen: continue seen.add(cn) uniq.append(c) free = [c for c in uniq if c["kind"] == "free"] strict = [c for c in uniq if c["kind"] == "strict"] print(f"{len(uniq)} distinct layouts: {len(free)} free, {len(strict)} strict", flush=True) rng = random.Random(2026) rng.shuffle(free) rng.shuffle(strict) half = YEAR // 2 days, picked = [], {"free": 0, "strict": 0} for kind, pool_ in (("free", free), ("strict", strict)): out = [] for c in pool_: if len(out) == half: break e = export(c, 0) if e is not None: out.append(e) print(f"{kind} {len(out):3d}/{half} {c['spec']:14s} {c['garden'].H}x{c['garden'].W} rakings={e['rakings']:>8,} par={e['par']:2d} at_par={e['at_par']:2d} {e['hint']}", flush=True) assert len(out) == half, f"only {len(out)} {kind} gardens" picked[kind] = out for k in range(YEAR): # even days free, odd days strict e = (picked["free"] if k % 2 == 0 else picked["strict"])[k // 2] e["name"] = f"daily-{k:03d}" days.append(e) assert len({(tuple(e["rows"]), tuple(e["gate"]), tuple(e["end"] or []), e["wide"], json.dumps(e["clues"], sort_keys=True), json.dumps(e["marks"], sort_keys=True)) for e in days}) == YEAR json.dump(days, open(OUT, "w")) sizes = {} for e in days: sizes[f"{len(e['rows'])}x{len(e['rows'][0])}"] = sizes.get(f"{len(e['rows'])}x{len(e['rows'][0])}", 0) + 1 kinds = {} for e in days: kinds[e["spec"].split(":")[-1][:5] if e["spec"].startswith("campaign") else e["spec"]] = kinds.get(e["spec"].split(":")[-1][:5] if e["spec"].startswith("campaign") else e["spec"], 0) + 1 stats_out = {"days": YEAR, "free": half, "strict": YEAR - half, "sizes": sizes, "from_campaign_leftovers": sum(e["spec"].startswith("campaign") for e in days), "with_ripple": sum(any("O" in r for r in e["rows"]) for e in days), "with_band": sum(any(ch in r for r in e["rows"] for ch in "hv") for e in days), "with_grooves": sum(bool(e["clues"]) for e in days), "with_pebbles": sum(bool(e["marks"]) for e in days), "wide": sum(e["wide"] for e in days), "side_gate": sum(bool(e["end"]) for e in days), "closed_ring": sum(any("R" in r for r in e["rows"]) for e in days), "max_at_par": MAX_AT_PAR, "bytes": os.path.getsize(OUT), "specs": stats, "seconds": round(time.time() - t0)} json.dump(stats_out, open(STATS, "w"), indent=1) print(json.dumps({k: v for k, v in stats_out.items() if k != "specs"}, indent=1), flush=True) if __name__ == "__main__": main() src/exp_clues.py import random, statistics, sys, time from multiprocessing import Pool from gen import random_layout from clues import make_unique def job(args): seed, H, W, k, variant = args rng = random.Random(seed * 31 + k) g = random_layout(H, W, k, rng, gate_side="S") if g is None: return None if variant == "wide": g.nohairpin = 2; g.braced_start = True r = g.solve(mode=0, cap=1, nodelimit=10**8) if r["nsol"] == 0: return None target = r["sols"][0]["draw"] clues = make_unique(g, target, rng) if clues is None: return ("fail",) return (len(clues), len(g.sand_cells())) if __name__ == "__main__": H, W = int(sys.argv[1]), int(sys.argv[2]) for variant in ["plain", "wide"]: for k in [0, 2, 4]: t0 = time.time() with Pool(2) as p: res = [x for x in p.map(job, [(s, H, W, k, variant) for s in range(120)]) if x] ok = [x for x in res if x[0] != "fail"] nc = [x[0] for x in ok] print(f"{variant:5s} {H}x{W} k={k}: n={len(ok)} fails={len(res)-len(ok)} clues median={statistics.median(nc) if nc else 0} " f"mean={statistics.mean(nc) if nc else 0:.1f} max={max(nc) if nc else 0} frac0={sum(c==0 for c in nc)/max(1,len(nc)):.2f} " f"clues/cell={statistics.mean(c/x[1] for c, x in zip(nc, ok)) if nc else 0:.3f} ({time.time()-t0:.0f}s)", flush=True) src/exp_constraints.py import random, statistics, time from multiprocessing import Pool from zg import Garden def place_ripples(H, W, n_ripple, n_plain, rng): rows = [["."] * W for _ in range(H)] tries = 0; placed = 0 while placed < n_ripple and tries < 500: tries += 1 r, c = rng.randint(1, H - 2), rng.randint(1, W - 2) if all(rows[a][b] == "." for a in range(r - 1, r + 2) for b in range(c - 1, c + 2)): # keep other ripple rings disjoint-ish: require 3x3 free rows[r][c] = "O"; placed += 1 placed = 0; tries = 0 while placed < n_plain and tries < 500: tries += 1 r, c = rng.randrange(H), rng.randrange(W) if rows[r][c] != ".": continue # don't break any ripple ring if any(0 <= r+dr < H and 0 <= c+dc < W and rows[r+dr][c+dc] == "O" for dr in (-1,0,1) for dc in (-1,0,1)): continue rows[r][c] = "#"; placed += 1 gc = rng.choice([c for c in range(W) if rows[H-1][c] == "."]) return Garden(["".join(x) for x in rows], (H - 1, gc, "S")) def place_band(H, W, rng, orient): rows = [["."] * W for _ in range(H)] if orient == "h": h = rng.randint(2, 3); w = rng.randint(3, W - 2) r0 = rng.randint(0, H - h - 1); c0 = rng.randint(1, W - w - 1) else: w = rng.randint(2, 3); h = rng.randint(3, H - 2) r0 = rng.randint(1, H - h - 1); c0 = rng.randint(0, W - w) for r in range(r0, r0 + h): for c in range(c0, c0 + w): rows[r][c] = orient # one or two plain stones away from band for _ in range(rng.randint(0, 2)): r, c = rng.randrange(H), rng.randrange(W) if rows[r][c] == ".": rows[r][c] = "#" gcs = [c for c in range(W) if rows[H-1][c] == "."] if not gcs: return None return Garden(["".join(x) for x in rows], (H - 1, rng.choice(gcs), "S")) def job(args): kind, seed, H, W = args rng = random.Random(seed) if kind.startswith("ripple"): n = int(kind[-1]) g = place_ripples(H, W, n, rng.randint(0, 2), rng) else: g = place_band(H, W, rng, kind[-1]) if g is None: return None r = g.solve(mode=0, cap=200000, nodelimit=3*10**8) return r["nsol"] if __name__ == "__main__": for kind in ["ripple1", "ripple2", "band_h", "band_v"]: for (H, W) in [(7, 7), (8, 8)]: t0 = time.time() with Pool(2) as p: res = [x for x in p.map(job, [(kind, s, H, W) for s in range(300)]) if x is not None] solv = [x for x in res if x > 0] print(f"{kind:8s} {H}x{W}: solvable={len(solv)/len(res):.3f} unique={sum(x==1 for x in res)/len(res):.3f} " f"1-20={sum(1<=x<=20 for x in res)/len(res):.3f} median={statistics.median(solv) if solv else 0:g} ({time.time()-t0:.0f}s)", flush=True) src/exp_continuity.py — Does the abbot's taste in worlds 1-3 match the wide rake's physics (worlds 5-7)? """Does the abbot's taste in worlds 1-3 match the wide rake's physics (worlds 5-7)? The generator keeps a free garden only if *some* par raking has no U-turn in open sand. Astra's review pointed out that this does not make *every* rewarded raking graceful. For each free garden of worlds 1-3 in the exported campaign, count the par rakings (all of them are stored) with and without an open-sand U-turn. Writes out/continuity.json. """ import json from zg import Garden, hairpin_list P = json.load(open('/home/claude/zen/out/proto_gardens.json')) STEP = {"N": (-1, 0), "S": (1, 0), "E": (0, 1), "W": (0, -1)} def garden_of(p): marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()} clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()} return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None, nohairpin=2 if p['wide'] else 0, braced_start=p['wide'], end_side=p['end_side']) def cells(p, ms): r, c = p['gate'][0], p['gate'][1] out = [(r, c)] for ch in ms: r, c = r + STEP[ch][0], c + STEP[ch][1] out.append((r, c)) return out rows = [] for p in P: if p['world'] > 3 or p['rakings'] == 1: continue g = garden_of(p) opened = [any(not braced for *_, braced in hairpin_list(g, cells(p, ms))) for ms in p['par_moves']] rows.append({"name": p["name"], "world": p["world"], "extra": p["extra"], "role": p["role"], "at_par": len(opened), "with_open_u": sum(opened)}) mixed = [r for r in rows if 0 < r["with_open_u"] < r["at_par"]] clean = [r for r in rows if r["with_open_u"] == 0] most = [r for r in rows if r["with_open_u"] * 2 > r["at_par"]] out = {"free_gardens": len(rows), "all_graceful": len(clean), "mixed": len(mixed), "majority_open_u": len(most), "none_graceful": sum(r["with_open_u"] == r["at_par"] for r in rows), "par_rakings": sum(r["at_par"] for r in rows), "par_rakings_with_open_u": sum(r["with_open_u"] for r in rows), "worst": max(rows, key=lambda r: (r["with_open_u"] / r["at_par"], r["at_par"])), "gardens": rows} json.dump(out, open('/home/claude/zen/out/continuity.json', 'w'), indent=1) print({k: v for k, v in out.items() if k != "gardens"}) src/exp_graceful.py import random, statistics, time, sys from gen import random_layout, parity_ok from zcp import optimize rng = random.Random(int(sys.argv[1])) rows = [] t0 = time.time() while len(rows) < int(sys.argv[2]): H, W = rng.choice([(6,7),(7,7),(7,8)]) g = random_layout(H, W, rng.choice([1,2,3]), rng, gate_side="S") if g is None or not parity_ok(g): continue if g.solve(mode=0, cap=1)['nsol'] == 0: continue t_free = optimize(g, time_limit=60) if t_free[1] != 'OPTIMAL': continue t_grace = optimize(g, nohairpin=2, time_limit=60) g.braced_start = True t_wide = optimize(g, nohairpin=2, time_limit=60) rows.append((t_free[0], t_free[4], t_grace[0], t_grace[1], t_wide[0], t_wide[1])) free = [r[0] for r in rows] same = sum(1 for r in rows if r[2] == r[0]) feas_grace = sum(1 for r in rows if r[2] is not None) feas_wide = sum(1 for r in rows if r[4] is not None) gaps = [r[2] - r[0] for r in rows if r[2] is not None] gapw = [r[4] - r[0] for r in rows if r[4] is not None] hp_at_par = [r[1] for r in rows] print(f"n={len(rows)} ({time.time()-t0:.0f}s)") print(f"graceful (no open hairpin) feasible: {feas_grace/len(rows):.2f}; graceful optimum == free par: {same/len(rows):.2f}; gap when feasible: mean {statistics.mean(gaps):.2f} max {max(gaps)}") print(f"wide rake feasible: {feas_wide/len(rows):.2f}; gap mean {statistics.mean(gapw) if gapw else float('nan'):.2f}") print("hairpins in (one) free-par solution: mean", statistics.mean(hp_at_par)) src/exp_marks.py — The abbot's marks vs the master's grooves: on the same gardens and the same intended raking (a graceful par """The abbot's marks vs the master's grooves: on the same gardens and the same intended raking (a graceful par raking), how many clues does each need for uniqueness, and how hard does the grader find the result?""" import json, random, statistics, sys, time from multiprocessing import Pool from gen import random_layout, parity_ok from zg import turns_of from zcp import optimize from clues import make_unique, with_clues, make_unique_marks, with_marks from grader import grade BUDGET = float(sys.argv[1]) if len(sys.argv) > 1 else 600 def job(seed): rng = random.Random(seed); out = []; t0 = time.time() while time.time() - t0 < BUDGET: H, W = rng.choice([(7, 7), (7, 8)]) g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0: continue free = optimize(g, time_limit=30); grace = optimize(g, nohairpin=2, time_limit=30) if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: continue target = list(reversed(grace[2])) s = rng.randrange(10**9) grooves = make_unique(g, target, random.Random(s)) marks = make_unique_marks(g, target, random.Random(s)) row = {"rows": g.rows, "gate": list(g.gate), "cells": len(target), "turns": turns_of(g, target), "target": [list(c) for c in target]} if grooves is not None: gr = grade(with_clues(g, grooves)) row.update(grooves={f"{r},{c}": v for (r, c), v in grooves.items()}, g_tier=gr["tier"], g_trials=gr["trials"]) if marks is not None: gr = grade(with_marks(g, marks)) row.update(marks={f"{r},{c}": v for (r, c), v in marks.items()}, m_tier=gr["tier"], m_trials=gr["trials"]) out.append(row) return out if __name__ == "__main__": with Pool(2) as p: rows = [x for r in p.map(job, [2000, 2001]) for x in r] json.dump(rows, open("/home/claude/zen/out/marks_vs_grooves.json", "w")) both = [r for r in rows if "grooves" in r and "marks" in r] print(f"gardens {len(rows)}; grooves ok {sum('grooves' in r for r in rows)}; marks ok {sum('marks' in r for r in rows)}; both {len(both)}") if both: print("clues median: grooves", statistics.median(len(r["grooves"]) for r in both), "marks", statistics.median(len(r["marks"]) for r in both)) for k in ("g", "m"): tiers = [r[f"{k}_tier"] for r in both] print(k, "tiers", {t: tiers.count(t) for t in sorted(set(tiers))}) src/exp_open.py import time from zg import Garden for (H, W) in [(3,3),(3,4),(4,4),(4,5),(5,5),(5,6),(6,6)]: for gate in [((H-1, 0, "W"), "corner"), ((H-1, W//2, "S"), "mid-edge")]: g = Garden(["."*W]*H, gate[0]) t0 = time.time() r = g.solve(mode=0, cap=10**12, timeout=3600) dt = time.time()-t0 h = r['hist']; n = r['nsol'] tmin = min(h) if h else None; tmax = max(h) if h else None frac_par = h[tmin]/n if n else 0 hp = r['hphist'] print(f"{H}x{W} {gate[1]:8s} n={n:>10} complete={r['complete']} minT={tmin} maxT={tmax} atPar={h.get(tmin,0)} ({frac_par:.4f}) zeroHairpin={hp.get(0,0)} nodes={r['nodes']} {dt:.1f}s", flush=True) src/exp_open2.py import time from zg import Garden for (H, W) in [(6,7),(7,7),(6,8)]: for gate in [((H-1, 0, "W"), "corner")]: g = Garden(["."*W]*H, gate[0]) t0 = time.time() r = g.solve(mode=0, cap=10**12, timeout=3600) dt = time.time()-t0 h = r['hist']; n = r['nsol'] tmin = min(h) if h else None; tmax = max(h) if h else None print(f"{H}x{W} {gate[1]:8s} n={n:>10} complete={r['complete']} minT={tmin} maxT={tmax} atPar={h.get(tmin,0)} ({h.get(tmin,0)/n:.5f}) zeroHairpin={r['hphist'].get(0,0)} nodes={r['nodes']} {dt:.1f}s", flush=True) print(' hist', sorted(h.items()), flush=True) src/exp_pipeline.py — Run the generation pipeline in bulk and record yield / difficulty statistics per mechanic. """Run the generation pipeline in bulk and record yield / difficulty statistics per mechanic.""" import json, random, statistics, sys, time from multiprocessing import Pool from gen import random_layout, parity_ok from zg import turns_of, hairpin_list, Garden from zcp import optimize from clues import make_unique, with_clues from grader import grade def grooves_job(seed): rng = random.Random(seed); out = []; t0 = time.time(); tried = 0 while time.time() - t0 < float(sys.argv[2]): H, W = rng.choice([(7, 7), (7, 8)]) g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) if g is None: continue tried += 1 if not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0: continue free = optimize(g, time_limit=30); grace = optimize(g, nohairpin=2, time_limit=30) if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: continue target = list(reversed(grace[2])) clues = make_unique(g, target, rng) if clues is None: continue gr = grade(with_clues(g, clues)) out.append({"cells": len(target), "clues": len(clues), "tier": gr["tier"], "trials": gr["trials"], "dens": turns_of(g, target) / len(target)}) return out, tried def wide_job(seed): rng = random.Random(seed); out = []; t0 = time.time(); tried = 0 while time.time() - t0 < float(sys.argv[2]): H, W = rng.choice([(7, 7), (7, 8), (8, 8)]) g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) if g is None: continue tried += 1 if not parity_ok(g): continue g.nohairpin = 2; g.braced_start = True r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue d = r["sols"][0]["draw"] dens = turns_of(g, d) / len(d) gr = grade(g) out.append({"cells": len(d), "clues": 0, "tier": gr["tier"], "trials": gr["trials"], "dens": dens}) return out, tried if __name__ == "__main__": kind = sys.argv[1] fn = grooves_job if kind == "grooves" else wide_job with Pool(2) as p: res = p.map(fn, [1000 + i for i in range(2)]) rows = [x for r, _ in res for x in r]; tried = sum(t for _, t in res) print(f"{kind}: layouts tried {tried}, levels {len(rows)} (yield {len(rows)/tried:.3f})") if not rows: sys.exit() for t in sorted(set(r['tier'] for r in rows)): sub = [r for r in rows if r['tier'] == t] print(f" tier {t}: {len(sub)/len(rows):.2f} median trials {statistics.median([r['trials'] or 0 for r in sub])}") print(f" clues median {statistics.median(r['clues'] for r in rows)} mean {statistics.mean(r['clues'] for r in rows):.1f}") pretty = [r for r in rows if r['dens'] <= 0.40] print(f" turn density median {statistics.median(r['dens'] for r in rows):.3f}; share with density<=0.40: {len(pretty)/len(rows):.2f}") json.dump(rows, open(f"/home/claude/zen/out/pipeline_{kind}.json", "w")) src/exp_sampler.py — Yields of the garden-like stone sampler (garden_sampler.py) against the single-cell sampler used in all earlier """Yields of the garden-like stone sampler (garden_sampler.py) against the single-cell sampler used in all earlier experiments (gen.random_layout, 1-3 1x1 stones kept apart), on 7x7 gardens with the gate on the south wall: free: parity, solvable, and par reachable with no U-turn in open sand ("graceful"); plus rakings at par; grooves: grooves added from the graceful par raking until unique (count, grade); wide: unique under the wide rake (yield, grade); side: unique under the wide rake with a random side gate (yield). Writes out/sampler_yields.json.""" import json, random, statistics, sys, time from multiprocessing import Pool from gen import random_layout, parity_ok from garden_sampler import garden_layout, add_side_gate from zcp import optimize from clues import make_unique, with_clues from grader import grade N = int(sys.argv[1]) if len(sys.argv) > 1 else 300 def layout(kind, rng): if kind == "single": return random_layout(7, 7, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) return garden_layout(7, 7, rng) def job(args): kind, seed = args rng = random.Random(seed) out = {"kind": kind, "layouts": 0, "parity": 0, "solvable": 0, "graceful": 0, "at_par": [], "grooves": [], "g_tiers": [], "wide_unique": 0, "wide_tiers": [], "side_unique": 0, "stone_cells": []} while out["layouts"] < N: g = layout(kind, rng) if g is None: continue out["layouts"] += 1 out["stone_cells"].append(sum(ch == "#" for row in g.rows for ch in row)) if not parity_ok(g): continue out["parity"] += 1 r = g.solve(mode=0, cap=10**6, nodelimit=5 * 10**8) if r["nsol"] == 0: continue out["solvable"] += 1 free = optimize(g, time_limit=20); grace = optimize(g, nohairpin=2, time_limit=20) if free[1] == "OPTIMAL" and grace[1] == "OPTIMAL" and grace[0] == free[0]: out["graceful"] += 1 if r["complete"] and not r["capped"]: out["at_par"].append(r["hist"].get(free[0], 0)) if len(out["grooves"]) < 25: cl = make_unique(g, list(reversed(grace[2])), rng) if cl is not None: out["grooves"].append(len(cl)); out["g_tiers"].append(grade(with_clues(g, cl))["tier"]) gw = Garden_copy(g); gw.nohairpin = 2; gw.braced_start = True rw = gw.solve(mode=0, cap=2) if rw["nsol"] == 1: out["wide_unique"] += 1 out["wide_tiers"].append(grade(gw)["tier"]) gs = Garden_copy(gw) if add_side_gate(gs, rng) and gs.solve(mode=0, cap=2)["nsol"] == 1: out["side_unique"] += 1 return out def Garden_copy(g): from copy import deepcopy return deepcopy(g) if __name__ == "__main__": t0 = time.time() with Pool(2) as p: res = p.map(job, [("single", 11), ("garden", 12)]) summary = {} for o in res: k = o["kind"]; n = o["layouts"] summary[k] = {"layouts": n, "parity_ok": round(o["parity"] / n, 3), "solvable": round(o["solvable"] / n, 3), "graceful_free": round(o["graceful"] / n, 3), "median_at_par": statistics.median(o["at_par"]) if o["at_par"] else None, "median_grooves": statistics.median(o["grooves"]) if o["grooves"] else None, "grooves_lookahead": round(sum(t >= 3 for t in o["g_tiers"]) / max(1, len(o["g_tiers"])), 2), "wide_unique": round(o["wide_unique"] / n, 3), "wide_nested": round(sum(t >= 4 for t in o["wide_tiers"]) / max(1, len(o["wide_tiers"])), 2), "side_unique": round(o["side_unique"] / n, 3), "mean_stone_cells": round(statistics.mean(o["stone_cells"]), 2)} print(k, summary[k], flush=True) json.dump({"summary": summary, "raw": res}, open("/home/claude/zen/out/sampler_yields.json", "w")) print(f"{time.time() - t0:.0f}s") src/exp_variants.py import random, sys, time, statistics, json from multiprocessing import Pool from gen import random_layout, parity_ok from zg import Garden VARIANTS = { "plain": dict(nohairpin=0, braced_turns=False), "nohairpin": dict(nohairpin=1, braced_turns=False), "braced_hairpin": dict(nohairpin=2, braced_turns=False), "braced_turns": dict(nohairpin=0, braced_turns=True), } CAP = 200000 def job(args): seed, H, W, k = args rng = random.Random(seed) g = random_layout(H, W, k, rng, gate_side="S") if g is None: return None out = {"parity": parity_ok(g)} for name, kw in VARIANTS.items(): g.nohairpin = kw["nohairpin"]; g.braced_turns = kw["braced_turns"] r = g.solve(mode=0, cap=CAP, nodelimit=3 * 10**8) out[name] = (r["nsol"], r["complete"], r["capped"]) return out if __name__ == "__main__": H, W, k, n = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]) t0 = time.time() with Pool(2) as p: res = [r for r in p.map(job, [(s, H, W, k) for s in range(n)]) if r] print(f"== {H}x{W}, {k} stones, gate on S edge, {len(res)} layouts ({time.time()-t0:.0f}s)") print(f" parity-feasible: {sum(r['parity'] for r in res)/len(res):.2f}") for name in VARIANTS: counts = [r[name][0] for r in res] capped = sum(r[name][2] for r in res) solv = [c for c in counts if c > 0] uniq = sum(c == 1 for c in counts) few = sum(1 <= c <= 10 for c in counts) med = statistics.median(solv) if solv else 0 print(f" {name:15s} solvable={len(solv)/len(res):.3f} unique={uniq/len(res):.3f} 1-10 sols={few/len(res):.3f} median#(solvable)={med:g} capped@{CAP}={capped}") src/exp_variants2.py import random, sys, time, statistics from multiprocessing import Pool from gen import random_layout, parity_ok VARIANTS = { "plain": dict(nohairpin=0, braced_start=False), "braced_hp": dict(nohairpin=2, braced_start=False), "wide_rake": dict(nohairpin=2, braced_start=True), } CAP = 200000 def job(args): seed, H, W, k = args rng = random.Random(seed * 7919 + k) g = random_layout(H, W, k, rng, gate_side="S") if g is None: return None out = {} for name, kw in VARIANTS.items(): g.nohairpin = kw["nohairpin"]; g.braced_start = kw["braced_start"] r = g.solve(mode=0, cap=CAP, nodelimit=3 * 10**8) out[name] = (r["nsol"], r["capped"]) return out if __name__ == "__main__": H, W, n = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]) for k in [0, 1, 2, 3, 4, 6]: t0 = time.time() with Pool(2) as p: res = [r for r in p.map(job, [(s, H, W, k) for s in range(n)]) if r] line = f"{H}x{W} k={k} ({time.time()-t0:.0f}s):" for name in VARIANTS: counts = [r[name][0] for r in res] solv = [c for c in counts if c > 0] uniq = sum(c == 1 for c in counts) few = sum(1 <= c <= 20 for c in counts) med = statistics.median(solv) if solv else 0 line += f" {name}: solv={len(solv)/len(res):.3f} uniq={uniq/len(res):.3f} 1-20={few/len(res):.3f} med={med:g}" print(line, flush=True) src/exp_wide_par.py — How often is a wide-rake-unique garden's only raking also a par raking of the bare garden? """How often is a wide-rake-unique garden's only raking also a par raking of the bare garden?""" import random, time, statistics, sys from gen import random_layout, parity_ok from zg import Garden, turns_of from zcp import optimize rng = random.Random(int(sys.argv[1])) rows = []; t0 = time.time() while time.time() - t0 < float(sys.argv[2]): H, W = rng.choice([(7, 7), (7, 8), (8, 8)]) g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g): continue g.nohairpin = 2; g.braced_start = True r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue t_wide = r["sols"][0]["turns"] p = optimize(Garden(g.rows, g.gate), time_limit=60, workers=2) if p[1] != "OPTIMAL": continue rows.append((t_wide, p[0], t_wide / len(r["sols"][0]["draw"]))) gaps = [a - b for a, b, _ in rows] print(f"n={len(rows)}; wide answer == bare par: {sum(g == 0 for g in gaps)/len(rows):.2f}; within 1: {sum(g <= 1 for g in gaps)/len(rows):.2f}; within 2: {sum(g <= 2 for g in gaps)/len(rows):.2f}; median gap {statistics.median(gaps)}") calm = [r for r in rows if r[2] <= 0.40] cg = [a - b for a, b, _ in calm] print(f"among turn density <= 0.40 (n={len(calm)}): == par {sum(g == 0 for g in cg)/max(1,len(calm)):.2f}; within 1: {sum(g <= 1 for g in cg)/max(1,len(calm)):.2f}") src/export_proto.py — Export the campaign (out/campaign.json, from campaign.py) for the prototype, re-deriving and checking everything: """Export the campaign (out/campaign.json, from campaign.py) for the prototype, re-deriving and checking everything: each garden's raking count (exact solver), par (CP-SAT), rakings at par, every par raking (as move strings, for the in-page hints), an intended raking (asserted valid and at par), and the first hints marked "auto" (hints.py).""" import json from zg import Garden, turns_of, check_solution from zcp import optimize from levels import from_dict, to_dict import copy from hints import hint_parity, far_cell, best_free_hint, best_strict_hint, near_misses, with_fact, first_dir, STRAIGHT_IN, gate_word from grader import grade from clues import with_marks CAMPAIGN = json.load(open('/home/claude/zen/out/campaign.json')) MOVE = {(-1, 0): "N", (1, 0): "S", (0, 1): "E", (0, -1): "W"} def moves_of(d): return "".join(MOVE[(b[0] - a[0], b[1] - a[1])] for a, b in zip(d, d[1:])) gardens = [] HINT_STATS = [] # the gardens that bring in something new are first raked once by the monk, the calm way, and then reset for the player: # the first garden (what raking is), the first one scored by turns, and the introduction of each later rule (the second # garden of each contrasting pair, and the two rules introduced mid-world without a pair) DEMO = {"The corridor", "Nine squares", "Ichimatsu", "The finished ring"} def demo_of(name, role, pair, extra): return not extra and (name in DEMO or (role == "intro" and pair is not None)) def add(name, world, g, intro=None, hint=None, tone=0.0, hint_cells=None, role=None, trap=0.0, extra=False, pair=None): r = g.solve(mode=0, cap=10**9, nodelimit=4 * 10**9) assert r['nsol'] > 0 and r['complete'] and not r['capped'], name free = optimize(g, time_limit=120) assert free[1] == 'OPTIMAL', name par = free[0] at_par = r['hist'].get(par, 0) # how many of the valid rakings reach par (exact: the count is complete) assert at_par > 0, name # intended raking: graceful par if available, else par grace = optimize(g, nohairpin=2, time_limit=120) if not g.nohairpin else free intended = list(reversed(grace[2])) if (grace[1] == 'OPTIMAL' and grace[0] == par) else list(reversed(free[2])) assert check_solution(g, intended) and turns_of(g, intended) == par, name # every par raking (the in-page hint decides "can this route still reach par?" exactly, by prefix) rp = g.solve(mode=2, bound=par, cap=10**6, stream=True) par_rakings = [s_["draw"] for s_ in rp["sols"]] assert rp["complete"] and not rp["capped"] and len(par_rakings) == rp["nsol"] == at_par, name assert all(check_solution(g, d_) and turns_of(g, d_) == par for d_ in par_rakings), name par_rakings.sort(key=lambda d_: d_ != intended) # the intended raking first assert par_rakings[0] == intended, name if role == "trap" and r['nsol'] == 1: # a strict trap teaches its trap: the only raking turns aside at once, though walking straight in looks fine gate = (g.gate[0], g.gate[1]) assert first_dir(intended) != STRAIGHT_IN[g.gate[2]], name hint, hint_cells = f"Walking straight in from the {gate_word(g)} goes wrong here: the line turns at once.", [list(gate)] before, after = grade(g), grade(with_fact(g, hint_cells, hint)) assert after["solved"], name HINT_STATS.append({"name": name, "kind": "strict", "trap": True, "tier": before["tier"], "tier_after": after["tier"], "trials": before["trials"] or 0, "trials_after": after["trials"] or 0}) elif hint in (None, "auto", "auto-parity"): if hint == "auto-parity": hint = hint_parity(g, [s_["draw"] for s_ in g.solve(mode=0, cap=10**6, stream=True)["sols"]]) hint_cells = [list(far_cell(g))] elif r['nsol'] == 1: # the fact about the unique raking that lets the grader's tier 1-2 rules decide the most; with it as a # given, how hard is the garden still? hint, hint_cells, (d0, d1) = best_strict_hint(g, intended) before = grade(g) after = grade(with_fact(g, hint_cells, hint)) if hint_cells else before assert after["solved"], name HINT_STATS.append({"name": name, "kind": "strict", "tier": before["tier"], "tier_after": after["tier"], "trials": before["trials"] or 0, "trials_after": after["trials"] or 0, "decided": [d0, d1]}) else: near = near_misses(g, par) hint, hint_cells, miss = best_free_hint(g, par, par_rakings, near) HINT_STATS.append({"name": name, "kind": "free", "near": len(near), "miss": miss}) d = to_dict(g) gardens.append({"name": name, "world": world, "rows": g.rows, "gate": list(g.gate), "end": list(g.end) if g.end else None, "end_side": g.end_side, "clues": d["clues"], "wide": g.nohairpin == 2, "par": par, "rakings": r['nsol'], "at_par": at_par, "intended": [list(c) for c in intended], "intro": intro, "marks": d["marks"], "par_moves": [moves_of(d_) for d_ in par_rakings], "hint": hint, "tone": tone, "hint_cells": hint_cells or [], "role": role, "trap": trap, "extra": bool(extra), "pair": pair, "demo": demo_of(name, role, pair, extra)}) print(f"{world} {name:22s} {g.H}x{g.W} rakings={r['nsol']:>9,} par={par:2d} at_par={at_par:3d} {hint}", flush=True) for e in CAMPAIGN: add(e["name"], e["world"], from_dict(e["garden"]), e.get("intro"), e.get("hint"), e.get("tone", 0.0), e.get("hint_cells"), e.get("role"), e.get("trap", 0.0), e.get("extra", False), e.get("pair")) assert len({g["name"] for g in gardens}) == len(gardens), "duplicate names" json.dump(gardens, open('/home/claude/zen/out/proto_gardens.json', 'w')) json.dump(HINT_STATS, open('/home/claude/zen/out/hint_stats.json', 'w'), indent=1) assert all(g["name"] in {x["name"] for x in gardens} for g in [{"name": n} for n in DEMO]), "a demonstration garden is missing" print(len(gardens), "gardens exported;", sum(not g["extra"] for g in gardens), "on the main path;", sum(g["demo"] for g in gardens), "with a demonstration:", ", ".join(g["name"] for g in gardens if g["demo"])) src/fig_pebbles.py — The abbot's pebbles figure (same garden and answer: grooves vs pebbles), plus the marks-vs-grooves """fig_pebbles.py: the abbot's pebbles figure (same garden and answer: grooves vs pebbles), plus the marks-vs-grooves statistics quoted in the document (from out/marks_vs_grooves.json, written by exp_marks.py).""" import json, os, statistics from zg import Garden, turns_of, check_solution from levels import from_dict from grader import grade from render import garden_svg, hstack, save FIG = "/home/claude/zen/fig/final"; PREFIX = "2026-10-03-gwern-samon-" mp = json.load(open("/home/claude/zen/out/marks_pick.json")) gm = from_dict(mp["garden"]) draw = [tuple(c) for c in mp["draw"]] grooves = {tuple(map(int, k.split(","))): v for k, v in mp["grooves"].items()} gg = Garden(gm.rows, gm.gate, clues=grooves) plain = Garden(gm.rows, gm.gate) rm, rg, rp = gm.solve(mode=0, cap=5), gg.solve(mode=0, cap=5), plain.solve(mode=0, cap=10**8) assert rm["nsol"] == 1 and rg["nsol"] == 1 and check_solution(gm, draw) and check_solution(gg, draw) grm, grg = grade(gm), grade(gg) facts = {"pebbles": len(gm.marks), "pale": sum(v == "S" for v in gm.marks.values()), "dark": sum(v == "B" for v in gm.marks.values()), "grooves": len(grooves), "plain_rakings": rp["nsol"], "par": min(rp["hist"]), "at_par": rp["hist"][min(rp["hist"])], "answer_turns": turns_of(gm, draw), "tier_pebbles": grm["tier"], "trials_pebbles": grm["trials"], "tier_grooves": grg["tier"]} panels = [garden_svg(gg, label=f"{len(grooves)} grooves", sublabel=f"tier {grg['tier']}", seed=5), garden_svg(gm, label=f"{len(gm.marks)} pebbles", sublabel=f"tier {grm['tier']} ({grm['trials']} lookahead steps)", seed=5), garden_svg(gm, draw=draw, label="The only raking", sublabel=f"{facts['answer_turns']} turns = par", seed=5)] save(hstack(panels), os.path.join(FIG, PREFIX + "pebbles.png"), os.path.join(FIG, PREFIX + "pebbles.svg")) rows = json.load(open("/home/claude/zen/out/marks_vs_grooves.json")) ng, nm = [len(r["grooves"]) for r in rows], [len(r["marks"]) for r in rows] gt, mt = [r["g_tier"] for r in rows], [r["m_tier"] for r in rows] facts["experiment"] = {"gardens": len(rows), "grooves_median": statistics.median(ng), "pebbles_median": statistics.median(nm), "share_more_pebbles": round(sum(b > a for a, b in zip(ng, nm)) / len(rows), 3), "grooves_lookahead_share": round(sum(t >= 3 for t in gt) / len(rows), 3), "pebbles_lookahead_share": round(sum(t >= 3 for t in mt) / len(rows), 3), "grooves_nested_share": round(sum(t >= 4 for t in gt) / len(rows), 3), "pebbles_nested_share": round(sum(t >= 4 for t in mt) / len(rows), 3), "grooves_median_trials": statistics.median([r["g_trials"] for r in rows if r["g_tier"] >= 3]), "pebbles_median_trials": statistics.median([r["m_trials"] for r in rows if r["m_tier"] >= 3])} json.dump(facts, open("/home/claude/zen/out/facts_pebbles.json", "w"), indent=1) print(json.dumps(facts, indent=1)) src/final_figs.py — Render all figures for the design document and record every number they show. """final_figs.py: render all figures for the design document and record every number they show.""" import json import os import statistics from zg import Garden, glyphs, DIRS, DR, DC, OPP, turns_of, check_solution from levels import from_dict, to_dict from render import garden_svg, hstack, save, SEAL, INK, S from zcp import optimize FIG = "/home/claude/zen/fig/final" os.makedirs(FIG, exist_ok=True) PREFIX = "2026-10-03-gwern-samon-" L = json.load(open("/home/claude/zen/out/levels.json")) facts = {} def out(name): return os.path.join(FIG, PREFIX + name + ".png") def svgout(name): return os.path.join(FIG, PREFIX + name + ".svg") def draw_of(lv): return [tuple(c) for c in lv["intended"]] # ------------------------------------------------------------------ 1. anatomy lv = L["first"] g = from_dict(lv["garden"]) draw = draw_of(lv) rake = list(reversed(draw)) gl = glyphs(g, draw) k = int(len(rake) * 0.45) raked_cells = rake[:k] partial = {c: gl[c] for c in raked_cells} foot_prefix = draw[: len(draw) - k] # cells still showing footprints (gate ... monk position) panels = [ garden_svg(g, label="1. the garden", seed=2), garden_svg(g, footprints=draw, monk=draw[-1], label="2. walk in", seed=2), garden_svg(g, glyph_override=partial, footprints=foot_prefix, monk=rake[k - 1], label="3. rake back out", seed=2), garden_svg(g, draw=draw, label=f"4. raked: {turns_of(g, draw)} turns", seed=2), ] sv = hstack(panels, gap=14) save(sv, out("anatomy"), svgout("anatomy")) facts["anatomy"] = {"rows": g.rows, "gate": g.gate, "turns": turns_of(g, draw), "par": lv["par"], "n_rakings": lv["n_rakings"], "at_par": lv["at_par"]} # ------------------------------------------------------------------ 2. calm vs agitated lv = L["calm"] g = from_dict(lv["garden"]) par_draw = draw_of(lv) r = g.solve(mode=0, cap=10**9) hist = {int(t): n for t, n in r["hist"].items()} total = sum(hist.values()) # median turn count over all valid rakings acc = 0 median_t = None for t in sorted(hist): acc += hist[t] if acc >= total / 2: median_t = t break mid = optimize(g, objective=None, fix={"turns_eq": median_t}, time_limit=60) mid_draw = list(reversed(mid[2])) worst_draw = r["worst"]["draw"] assert check_solution(g, mid_draw) and check_solution(g, worst_draw) and check_solution(g, par_draw) panels = [ garden_svg(g, draw=par_draw, label=f"par: {turns_of(g, par_draw)} turns", seed=5), garden_svg(g, draw=mid_draw, label=f"median: {turns_of(g, mid_draw)} turns", seed=5), garden_svg(g, draw=worst_draw, label=f"most agitated: {turns_of(g, worst_draw)} turns", seed=5), ] save(hstack(panels, gap=14), out("calm-agitated"), svgout("calm-agitated")) facts["calm"] = {"rows": g.rows, "gate": g.gate, "n_rakings": total, "par": min(hist), "at_par": hist[min(hist)], "median_turns": median_t, "max_turns": max(hist), "at_max": hist[max(hist)], "mode_turns": max(hist, key=hist.get), "hist": hist} # ------------------------------------------------------------------ 3. histogram (static figure; single series + one emphasised bar) W_, H_ = 760, 330 ml, mr, mt, mb = 70, 24, 24, 58 pw, ph = W_ - ml - mr, H_ - mt - mb ts = list(range(min(hist), max(hist) + 1)) ymax = 10000 slot = pw / len(ts) bw = min(24, slot - 2) el = [f'', f''] for yv in range(0, ymax + 1, 2000): y = mt + ph - ph * yv / ymax el.append(f'') el.append(f'{yv:,}') for i, t in enumerate(ts): n = hist.get(t, 0) x = ml + i * slot + (slot - bw) / 2 h = ph * n / ymax y = mt + ph - h color = SEAL if t == min(hist) else "#8e9aa6" if h > 0: rr = min(4, h, bw / 2) # rounded data end, square at the baseline el.append(f'') if t % 5 == 0: el.append(f'{t}') el.append(f'') el.append(f'turns in the raking') el.append(f'number of valid rakings') # par annotation with leader i0 = ts.index(min(hist)) px = ml + i0 * slot + slot / 2 el.append(f'') el.append(f'') el.append(f'par: {min(hist)} turns') el.append(f'{hist[min(hist)]} of {total:,} rakings') im = ts.index(median_t) mx = ml + im * slot + slot / 2 my = mt + ph - ph * hist[median_t] / ymax el.append(f'median: {median_t} turns') el.append("") save("\n".join(el), out("histogram"), svgout("histogram")) # ------------------------------------------------------------------ 4. parity g_ok = Garden(["....."] * 5, (4, 0, "S")) g_bad = Garden(["....."] * 5, (4, 1, "S")) r_ok = g_ok.solve(mode=0, cap=10**9) r_bad = g_bad.solve(mode=0, cap=10**9) best_ok = optimize(g_ok) panels = [garden_svg(g_ok, draw=list(reversed(best_ok[2])), checker=True, label=f"gate on a dark square: {r_ok['nsol']} rakings", seed=1), garden_svg(g_bad, checker=True, label=f"gate on a light square: {r_bad['nsol']} rakings", seed=1)] save(hstack(panels, gap=14), out("parity"), svgout("parity")) facts["parity"] = {"ok_rakings": r_ok["nsol"], "bad_rakings": r_bad["nsol"]} # ------------------------------------------------------------------ 5-8, 10-11: puzzle | solution pairs def pair(key_or_garden, draw, name, left="the garden", right=None, seed=3, g=None): g = g or from_dict(L[key_or_garden]["garden"]) right = right or f"its raking: {turns_of(g, draw)} turns" sv = hstack([garden_svg(g, label=left, seed=seed), garden_svg(g, draw=draw, label=right, seed=seed)], gap=14) save(sv, out(name), svgout(name)) return g for key, name in [("ripple", "ripple"), ("waves", "waves"), ("grooves", "grooves")]: lv = L[key] g = pair(key, draw_of(lv), name, right=(f"unique raking: {lv['intended_turns']} turns" if lv["n_rakings"] == 1 else f"par raking: {lv['intended_turns']} turns")) facts[key] = {k2: lv[k2] for k2 in ("n_rakings", "par", "at_par", "graceful_par", "intended_turns", "grade") if k2 in lv} facts[key]["rows"] = g.rows facts[key]["gate"] = g.gate facts[key]["clues"] = len(g.clues) # ichimatsu 9x9 gi = Garden(["".join("=" if ((r // 3) + (c // 3)) % 2 == 0 else "|" for c in range(9)) for r in range(9)], (8, 0, "S")) ri = gi.solve(mode=0, cap=100) pair(None, ri["sols"][0]["draw"], "ichimatsu", g=gi, right=f"one of its {ri['nsol']} rakings: {ri['sols'][0]['turns']} turns") facts["ichimatsu"] = {"n_rakings": ri["nsol"], "turns": [s["turns"] for s in ri["sols"]], "rows": gi.rows} # wide rake: chosen candidate wide = json.load(open("/home/claude/zen/out/wide_pick.json")) gw = from_dict(wide["garden"]) dw = [tuple(c) for c in wide["draw"]] assert check_solution(gw, dw) assert gw.solve(mode=0, cap=2)["nsol"] == 1 gw_free = Garden(gw.rows, gw.gate) rf = gw_free.solve(mode=0, cap=10**9, nodelimit=3 * 10**9) pair(None, dw, "widerake", g=gw, right=f"its only raking: {turns_of(gw, dw)} turns") facts["widerake"] = {"rows": gw.rows, "gate": gw.gate, "turns": turns_of(gw, dw), "grade": wide.get("grade"), "plain_rakings": rf["nsol"], "plain_complete": rf["complete"] and not rf["capped"], "plain_par": min(rf["hist"]) if rf["hist"] else None} # two gates two = json.load(open("/home/claude/zen/out/two_pick.json")) gt = from_dict(two["garden"]) dt = [tuple(c) for c in two["draw"]] assert check_solution(gt, dt) and gt.solve(mode=0, cap=2)["nsol"] == 1 pair(None, dt, "twogates", g=gt, right=f"its only raking: {turns_of(gt, dt)} turns") facts["twogates"] = {"rows": gt.rows, "gate": gt.gate, "end": gt.end, "end_side": gt.end_side, "turns": turns_of(gt, dt), "grade": two.get("grade")} # ------------------------------------------------------------------ 9. the wide-rake rule def frag_glyphs(cells, ext_start, ext_end): """Glyphs along a fragment; ends extended straight in the given directions.""" gl = {} for i, c in enumerate(cells): ds = set() if i > 0: p = cells[i - 1] ds.add([d for d in DIRS if (c[0] + DR[d], c[1] + DC[d]) == p][0]) else: ds.add(ext_start) if i + 1 < len(cells): n = cells[i + 1] ds.add([d for d in DIRS if (c[0] + DR[d], c[1] + DC[d]) == n][0]) else: ds.add(ext_end) gl[c] = "".join(ds) return gl def cross(ox, oy, r, c, ok): x, y = ox + c * S + S / 2, oy + r * S + S / 2 if ok: return f'' return (f'') ga = Garden(["......"] * 5, (4, 0, "S")) fa = [(1, 0), (1, 1), (1, 2), (1, 3), (2, 3), (2, 2), (2, 1), (2, 0)] gb = Garden(["...."] * 5, (4, 0, "S")) fb = [(1, 0), (1, 1), (1, 2), (1, 3), (2, 3), (2, 2), (2, 1), (2, 0)] gc = Garden(["......", "......", "...#..", "......", "......"], (4, 0, "S")) fc = [(1, 0), (1, 1), (1, 2), (1, 3), (1, 4), (2, 4), (3, 4), (3, 3), (3, 2), (3, 1), (3, 0)] panels = [ garden_svg(ga, glyph_override=frag_glyphs(fa, "W", "W"), show_gate_arrow=False, marks_svg=lambda ox, oy: cross(ox, oy, 1.5, 4.3, False), label="open sand: forbidden", seed=7, show_gate=False), garden_svg(gb, glyph_override=frag_glyphs(fb, "W", "W"), show_gate_arrow=False, marks_svg=lambda ox, oy: cross(ox, oy, 3.5, 2.0, True), label="against a wall: allowed", seed=7, show_gate=False), garden_svg(gc, glyph_override=frag_glyphs(fc, "W", "W"), show_gate_arrow=False, marks_svg=lambda ox, oy: cross(ox, oy, 2, 5, True), label="around a stone: allowed", seed=7, show_gate=False), ] save(hstack(panels, gap=14), out("widerake-rule"), svgout("widerake-rule")) json.dump(facts, open("/home/claude/zen/out/facts.json", "w"), indent=1, default=str) print(json.dumps({k: {kk: vv for kk, vv in v.items() if kk not in ("hist", "rows")} for k, v in facts.items()}, indent=1, default=str)) # ------------------------------------------------------------------ thumbnail: the wide-rake garden, raked sv = garden_svg(gw, draw=dw, seed=3, highlight_start=False) save(sv, out("thumbnail"), svgout("thumbnail"), scale=2.5) src/final_tables.py — Final numbers for the document's tables (run in the background; prints Markdown-ready rows). """Final numbers for the document's tables (run in the background; prints Markdown-ready rows).""" import random, statistics, sys, time, json from multiprocessing import Pool from zg import Garden from gen import random_layout, parity_ok def median_from_hist(h): tot = sum(h.values()); acc = 0 for t in sorted(h): acc += h[t] if acc >= tot / 2: return t def table1(): rows = [] for (H, W) in [(3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (6, 8)]: g = Garden(["." * W] * H, (H - 1, 0, "S")) r = g.solve(mode=0, cap=10**12) h = r["hist"] gw = Garden(["." * W] * H, (H - 1, 0, "S"), nohairpin=2, braced_start=True) rw = g.solve(mode=0, cap=1) # placeholder to keep API symmetric rw = gw.solve(mode=0, cap=10**9) rows.append(dict(size=f"{H}x{W}", rakings=r["nsol"], complete=r["complete"], par=min(h), at_par=h[min(h)], median=median_from_hist(h), max=max(h), wide=rw["nsol"], wide_turns=sorted(rw["hist"].items()))) print(rows[-1], flush=True) json.dump(rows, open("/home/claude/zen/out/table1.json", "w")) RULES = {"one stroke": dict(nohairpin=0, braced_start=False), "no U-turns at all": dict(nohairpin=1, braced_start=False), "wide rake": dict(nohairpin=2, braced_start=True)} CAP = 100000 def job(args): seed, k = args rng = random.Random(seed * 1009 + k) g = random_layout(7, 7, k, rng, gate_side="S") if g is None: return None out = {"parity": parity_ok(g)} for name, kw in RULES.items(): g.nohairpin = kw["nohairpin"]; g.braced_start = kw["braced_start"] r = g.solve(mode=0, cap=CAP, nodelimit=5 * 10**8) out[name] = (r["nsol"], r["capped"]) return out def table2(n): res_all = {} for k in [0, 2, 4, 6]: with Pool(2) as p: res = [r for r in p.map(job, [(s, k) for s in range(n)]) if r] res_all[k] = res line = f"k={k} n={len(res)} parity-ok={sum(r['parity'] for r in res)/len(res):.2f}" for name in RULES: counts = [r[name][0] for r in res] solv = [c for c in counts if c > 0] capped = sum(1 for r in res if r[name][1]) med = statistics.median(solv) if solv else 0 line += f" | {name}: solvable {len(solv)/len(res):.3f} unique {sum(c == 1 for c in counts)/len(res):.3f} median {med:g} capped {capped}" print(line, flush=True) json.dump({k: v for k, v in res_all.items()}, open("/home/claude/zen/out/table2.json", "w")) if __name__ == "__main__": if sys.argv[1] == "1": table1() else: table2(int(sys.argv[2])) src/garden_sampler.py — Garden-like layouts for the campaign. """garden_sampler.py: garden-like layouts for the campaign. Stones come in an odd number of pieces (1, 3 or 5), each piece 1-3 cells (single, I2, I3 or L3), kept apart (no two pieces within one cell of each other, diagonals included), placed so the layout has no left-right, top-bottom or 180-degree symmetry (karesansui prize asymmetry, fukinsei), and covering at most ~15% of the garden. Optional features: a ripple stone (O, with its 8-cell ring of sand), a wave band (h/v rectangle), a closed ring (a stone whose 8 neighbours are already raked: R cells, which are obstacles to the stroke), and a side gate. """ from __future__ import annotations import random from zg import Garden PIECES = [ # (weight, cells) (9, [(0, 0)]), (4, [(0, 0), (0, 1)]), (4, [(0, 0), (1, 0)]), (1, [(0, 0), (0, 1), (0, 2)]), (1, [(0, 0), (1, 0), (2, 0)]), (1, [(0, 0), (1, 0), (1, 1)]), (1, [(0, 0), (0, 1), (1, 0)]), (1, [(0, 0), (0, 1), (1, 1)]), (1, [(0, 1), (1, 0), (1, 1)]), ] def _free(rows, cells, H, W, gap): for r, c in cells: if not (0 <= r < H and 0 <= c < W) or rows[r][c] != ".": return False for dr in range(-gap, gap + 1): for dc in range(-gap, gap + 1): a, b = r + dr, c + dc if 0 <= a < H and 0 <= b < W and rows[a][b] in "#OR": return False return True def symmetric(rows): """Stones (any of #, O, R) symmetric under a left-right or top-bottom mirror, or a half turn.""" H, W = len(rows), len(rows[0]) S = {(r, c) for r in range(H) for c in range(W) if rows[r][c] in "#OR"} if not S: return True return any(S == {f(r, c) for r, c in S} for f in (lambda r, c: (r, W - 1 - c), lambda r, c: (H - 1 - r, c), lambda r, c: (H - 1 - r, W - 1 - c))) def add_ripple(rows, rng, H, W): for _ in range(200): r, c = rng.randint(1, H - 2), rng.randint(1, W - 2) block = [(r + a, c + b) for a in (-1, 0, 1) for b in (-1, 0, 1)] if all(rows[a][b] == "." for a, b in block) and _free(rows, block, H, W, 1): rows[r][c] = "O" return True return False def add_closed_ring(rows, rng, H, W): for _ in range(200): r, c = rng.randint(1, H - 2), rng.randint(1, W - 2) block = [(r + a, c + b) for a in (-1, 0, 1) for b in (-1, 0, 1)] if all(rows[a][b] == "." for a, b in block) and _free(rows, block, H, W, 1): for a, b in block: rows[a][b] = "R" rows[r][c] = "#" return True return False def add_band(rows, rng, H, W, sym=None): sym = sym or rng.choice("hv") for _ in range(200): if sym == "h": h, w = rng.randint(1, 2), rng.randint(3, W - 2) else: h, w = rng.randint(3, H - 2), rng.randint(1, 2) r, c = rng.randrange(0, H - h + 1), rng.randrange(0, W - w + 1) cells = [(r + a, c + b) for a in range(h) for b in range(w)] if all(rows[a][b] == "." for a, b in cells): for a, b in cells: rows[a][b] = sym return True return False def add_stones(rows, rng, H, W, n_pieces, max_frac=0.15, gap=1): placed, tries = 0, 0 budget = max(1, int(H * W * max_frac)) - sum(ch in "#" for row in rows for ch in row) weights = [w for w, _ in PIECES] while placed < n_pieces and tries < 500: tries += 1 cells0 = rng.choices([p for _, p in PIECES], weights)[0] if len(cells0) > budget - (n_pieces - placed - 1): continue r, c = rng.randrange(H), rng.randrange(W) cells = [(r + a, c + b) for a, b in cells0] if not _free(rows, cells, H, W, gap): continue for a, b in cells: rows[a][b] = "#" budget -= len(cells) placed += 1 return placed == n_pieces def garden_layout(H, W, rng, n_pieces=None, ripple=False, band=None, closed_ring=False, gate_side="S", max_frac=0.15): """A garden-like layout, or None if the constraints could not be met.""" rows = [["."] * W for _ in range(H)] if ripple and not add_ripple(rows, rng, H, W): return None if closed_ring and not add_closed_ring(rows, rng, H, W): return None if band and not add_band(rows, rng, H, W, None if band is True else band): return None if n_pieces is None: n_pieces = rng.choice([1, 3, 3]) if H * W < 49 else rng.choice([1, 3, 3, 5]) extra = sum(1 for row in rows for ch in row if ch in "O#") # a ripple stone or ringed stone counts as a piece n_pieces = max(0, n_pieces - extra) if n_pieces and not add_stones(rows, rng, H, W, n_pieces, max_frac=max_frac): return None if (n_pieces + extra) % 2 == 0 or symmetric(rows): return None if gate_side == "S": cand = [(H - 1, c) for c in range(W)] elif gate_side == "N": cand = [(0, c) for c in range(W)] elif gate_side == "W": cand = [(r, 0) for r in range(H)] else: cand = [(r, W - 1) for r in range(H)] cand = [p for p in cand if rows[p[0]][p[1]] in ".hv"] if not cand: return None gr, gc = rng.choice(cand) return Garden(["".join(r) for r in rows], (gr, gc, gate_side)) def add_side_gate(g: Garden, rng): """Pick a second gate on the north, east or west wall (the stroke must begin there).""" opts = [] for c in range(g.W): if g.rows[0][c] == ".": opts.append(((0, c), "N")) for r in range(g.H - 1): if g.rows[r][0] == ".": opts.append(((r, 0), "W")) if g.rows[r][g.W - 1] == ".": opts.append(((r, g.W - 1), "E")) if not opts: return False (cell, side) = rng.choice(opts) if cell == (g.gate[0], g.gate[1]): return False g.end, g.end_side = cell, side return True def ichimatsu(nbr, nbc, b, gate_col, flip=False): """A whole garden of b x b blocks raked alternately across (=) and down (|).""" rows = [] for r in range(nbr * b): rows.append("".join(("=" if ((r // b) + (c // b) + flip) % 2 == 0 else "|") for c in range(nbc * b))) return Garden(rows, (nbr * b - 1, gate_col, "S")) src/gen.py — Random garden layouts for experiments and generation. """Random garden layouts for experiments and generation.""" import random from zg import Garden def random_layout(H, W, n_stones, rng, shapes=((1,1),), gate_side=None, min_gap=0): rows = [["."] * W for _ in range(H)] placed = 0; tries = 0 while placed < n_stones and tries < 1000: tries += 1 h, w = rng.choice(shapes) r, c = rng.randrange(0, H - h + 1), rng.randrange(0, W - w + 1) cells = [(r + i, c + j) for i in range(h) for j in range(w)] if any(rows[a][b] != "." for a, b in cells): continue if min_gap: near = [(a + da, b + db) for a, b in cells for da in range(-min_gap, min_gap + 1) for db in range(-min_gap, min_gap + 1)] if any(0 <= a < H and 0 <= b < W and rows[a][b] != "." for a, b in near): continue for a, b in cells: rows[a][b] = "#" placed += 1 side = gate_side or rng.choice("NESW") if side == "S": cand = [(H - 1, c) for c in range(W)] elif side == "N": cand = [(0, c) for c in range(W)] elif side == "W": cand = [(r, 0) for r in range(H)] else: cand = [(r, W - 1) for r in range(H)] cand = [p for p in cand if rows[p[0]][p[1]] == "."] if not cand: return None gr, gc = rng.choice(cand) return Garden(["".join(r) for r in rows], (gr, gc, side)) def parity_ok(g): cells = g.sand_cells() b = sum((r + c) % 2 == 0 for r, c in cells); w = len(cells) - b gr, gc, _ = g.gate gb = (gr + gc) % 2 == 0 if abs(b - w) > 1: return False if b == w + 1 and not gb: return False if w == b + 1 and gb: return False return True src/grader.py — Human-style deductive solver for one-stroke raking puzzles, used to grade difficulty. """grader.py: human-style deductive solver for one-stroke raking puzzles, used to grade difficulty. Edge states: 0 unknown, 1 raked (ON), -1 not raked (OFF). The gate's exit half-edge is always ON. Technique tiers: tier 1 (local): saturation, forcing, dead-end => rake-start, no loops, given grooves/bands, ripple-ring counting, wide-rake hairpin exclusion, shape marks (straight / turn). tier 2 (global): checkerboard parity of the rake-start, connectivity of unraked sand, no premature closure of the stroke. tier 3 (trial): assume an edge, propagate tiers 1-2, refute by contradiction (depth 1). tier 4: nested trials (depth 2). grade() returns the highest tier needed plus counts. """ from __future__ import annotations from zg import Garden, DIRS, DR, DC, OPP PERP = {"N": "EW", "S": "EW", "E": "NS", "W": "NS"} class Contradiction(Exception): pass class Board: def __init__(self, g: Garden): self.g = g self.cells = g.sand_cells() self.idx = {c: i for i, c in enumerate(self.cells)} self.n = len(self.cells) self.gate = self.idx[(g.gate[0], g.gate[1])] self.edges = [] # (u, v) with u < v self.eid = {} # (u, v) and (v, u) -> edge id self.inc = [[] for _ in range(self.n)] # incident edge ids self.dir_edge = [dict() for _ in range(self.n)] # direction -> edge id for c in self.cells: u = self.idx[c] for d in "ES": nb = (c[0] + DR[d], c[1] + DC[d]) if nb in self.idx: v = self.idx[nb] e = len(self.edges) self.edges.append((u, v)) self.eid[(u, v)] = self.eid[(v, u)] = e self.inc[u].append(e) self.inc[v].append(e) self.dir_edge[u][d] = e self.dir_edge[v][OPP[d]] = e # start candidates cand = set(range(self.n)) if self.n > 1: cand.discard(self.gate) if g.end is not None: cand = {self.idx[tuple(g.end)]} if g.braced_start: cand = {u for u in cand if any(g.pivot(self.cells[u][0] + DR[d], self.cells[u][1] + DC[d]) for d in DIRS)} self.marks = {self.idx[c]: t for c, t in g.marks.items()} cand -= set(self.marks) # a marked cell has two connections, so it is never the rake start self.cand0 = cand # parity-derived candidates (tier 2) black = [((r + c) % 2 == 0) for r, c in self.cells] nb_, nw_ = sum(black), self.n - sum(black) gate_black = black[self.gate] if nb_ == nw_: want = not gate_black elif nb_ == nw_ + 1: want = True elif nw_ == nb_ + 1: want = False else: want = None # impossible garden self.parity_cand = {u for u in range(self.n) if want is not None and black[u] == want} self.parity_impossible = want is None or (nb_ == nw_ + 1 and not gate_black) or (nw_ == nb_ + 1 and gate_black) # fixed edges from bands and grooves self.fixed = {} for c, need in g.zone_requirements().items(): for d in need: self._fix(c, d, 1) for c, want_dirs in g.clues.items(): for d in DIRS: self._fix(c, d, 1 if d in want_dirs else -1) # ripple rings self.rings = [] for ring in g.ring_sets(): S = {self.idx[c] for c in ring} cross = [e for u in S for e in self.inc[u] if (self.edges[e][0] in S) != (self.edges[e][1] in S)] internal = [e for e in range(len(self.edges)) if self.edges[e][0] in S and self.edges[e][1] in S] self.rings.append((S, cross, internal)) # hairpin triples (e_arm_u, e_mid, e_arm_v) that are forbidden self.hairpins = [] if g.nohairpin: for c in self.cells: u = self.idx[c] for b in "ES": # mid edge u -> v in direction b if b not in self.dir_edge[u]: continue em = self.dir_edge[u][b] v = self.edges[em][1] if self.edges[em][0] == u else self.edges[em][0] for a in PERP[b]: # arms go in direction -a from u and v (the U's bottom faces a) back = OPP[a] eu = self.dir_edge[u].get(back) ev = self.dir_edge[v].get(back) cu, cv = self.cells[u], self.cells[v] braced = g.pivot(cu[0] + DR[a], cu[1] + DC[a]) and g.pivot(cv[0] + DR[a], cv[1] + DC[a]) if g.nohairpin == 2 and braced: continue # arms may also be the gate's exit half-edge arm_u = eu if eu is not None else ("X" if (u == self.gate and g.gate[2] == back) else None) arm_v = ev if ev is not None else ("X" if (v == self.gate and g.gate[2] == back) else None) if arm_u is None or arm_v is None: continue self.hairpins.append((arm_u, em, arm_v)) def _fix(self, c, d, val): u = self.idx[c] if c == (self.g.gate[0], self.g.gate[1]) and d == self.g.gate[2]: if val == -1: self.fixed["contradiction"] = True return if d not in self.dir_edge[u]: if val == 1: self.fixed["contradiction"] = True return e = self.dir_edge[u][d] if self.fixed.get(e, val) != val: self.fixed["contradiction"] = True self.fixed[e] = val class State: def __init__(self, B: Board, use_global=True): self.B = B self.s = [0] * len(B.edges) self.cand = set(B.cand0) if use_global: self.cand &= B.parity_cand self.use_global = use_global self.start = None def copy(self): o = State.__new__(State) o.B, o.s, o.cand, o.use_global, o.start = self.B, list(self.s), set(self.cand), self.use_global, self.start return o def set(self, e, val): if self.s[e] == val: return False if self.s[e] != 0: raise Contradiction self.s[e] = val return True def ondeg(self, u): d = sum(1 for e in self.B.inc[u] if self.s[e] == 1) return d + (1 if u == self.B.gate else 0) def unk(self, u): return [e for e in self.B.inc[u] if self.s[e] == 0] def hp_val(self, x): return 1 if x == "X" else self.s[x] def dstate(self, u, d): """State of cell u's side d: the gate's exit is ON; a side with no edge (wall, stone) is OFF.""" if u == self.B.gate and d == self.B.g.gate[2]: return 1 e = self.B.dir_edge[u].get(d) return -1 if e is None else self.s[e] def dset(self, u, d, val): if u == self.B.gate and d == self.B.g.gate[2]: if val != 1: raise Contradiction return False e = self.B.dir_edge[u].get(d) if e is None: if val == 1: raise Contradiction return False return self.set(e, val) def propagate(self): B = self.B if B.fixed.get("contradiction"): raise Contradiction if self.use_global and B.parity_impossible: raise Contradiction for e, v in B.fixed.items(): if e != "contradiction": self.set(e, v) changed = True while changed: changed = False if not self.cand: raise Contradiction if len(self.cand) == 1: self.start = next(iter(self.cand)) # cell rules for u in range(B.n): on, un = self.ondeg(u), self.unk(u) is_start = self.start == u maxdeg = 1 if is_start else 2 if on > maxdeg: raise Contradiction if on == maxdeg and un: for e in un: changed |= self.set(e, -1) continue if on + len(un) == 0: raise Contradiction if on + len(un) == 1: # dead end: must be the rake start if u not in self.cand: raise Contradiction if self.start != u: self.cand = {u} self.start = u changed = True for e in un: changed |= self.set(e, 1) continue if u not in self.cand and on + len(un) == 2: for e in un: changed |= self.set(e, 1) if is_start and on + len(un) >= 1 and on == 0 and len(un) == 1: changed |= self.set(un[0], 1) # shape marks: 'S' = both sides of one axis ON (the other OFF); 'B' = exactly one side of each axis ON for u, t in B.marks.items(): st = {d: self.dstate(u, d) for d in DIRS} if t == "S": for a, b, oa, ob in (("N", "S", "E", "W"), ("E", "W", "N", "S")): if st[a] == 1 or st[b] == 1: for d, v in ((a, 1), (b, 1), (oa, -1), (ob, -1)): changed |= self.dset(u, d, v) break if st[a] == -1 or st[b] == -1: for d, v in ((a, -1), (b, -1), (oa, 1), (ob, 1)): changed |= self.dset(u, d, v) break else: for a, b in (("N", "S"), ("E", "W")): if st[a] == st[b] != 0: raise Contradiction if st[a] != 0: changed |= self.dset(u, b, -st[a]) elif st[b] != 0: changed |= self.dset(u, a, -st[b]) # fragments (union-find over ON edges): forbid loops, premature closure parent = list(range(B.n)) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for e, (u, v) in enumerate(B.edges): if self.s[e] == 1: ru, rv = find(u), find(v) if ru == rv: raise Contradiction parent[ru] = rv size = {} for u in range(B.n): size[find(u)] = size.get(find(u), 0) + 1 for e, (u, v) in enumerate(B.edges): if self.s[e] != 0: continue ru, rv = find(u), find(v) if ru == rv: changed |= self.set(e, -1) continue if self.use_global and self.start is not None: terminals = {find(B.gate), find(self.start)} if {ru, rv} == terminals and size[ru] + size[rv] < B.n: changed |= self.set(e, -1) # hairpins for (a, m, b) in B.hairpins: vals = [self.hp_val(a), self.hp_val(m), self.hp_val(b)] if vals.count(1) == 3: raise Contradiction if vals.count(1) == 2 and 0 in vals: x = (a, m, b)[vals.index(0)] if x != "X": changed |= self.set(x, -1) # rings for (S, cross, internal) in B.rings: gate_in = B.gate in S p_true = self.start is not None and self.start in S p_poss = bool(self.cand & S) maxT = 2 - gate_in - (1 if p_true else 0) minT = 2 - gate_in - (1 if p_poss else 0) on = sum(1 for e in cross if self.s[e] == 1) un = [e for e in cross if self.s[e] == 0] if on > maxT or on + len(un) < minT: raise Contradiction if on == maxT: for e in un: changed |= self.set(e, -1) elif minT == maxT and on + len(un) == minT: for e in un: changed |= self.set(e, 1) ion = sum(1 for e in internal if self.s[e] == 1) ioff = [e for e in internal if self.s[e] == -1] iun = [e for e in internal if self.s[e] == 0] need = len(S) - 1 if ion > need or ion + len(iun) < need: raise Contradiction if ion == need: for e in iun: changed |= self.set(e, -1) elif ion + len(iun) == need: for e in iun: changed |= self.set(e, 1) # connectivity of the sand through non-OFF edges if self.use_global: seen = {B.gate} stack = [B.gate] while stack: u = stack.pop() for e in B.inc[u]: if self.s[e] != -1: a, b = B.edges[e] w = b if a == u else a if w not in seen: seen.add(w) stack.append(w) if len(seen) != B.n: raise Contradiction return self def solved(self): return all(x != 0 for x in self.s) def unknown_edges(self): return [e for e, x in enumerate(self.s) if x == 0] def valid_final(self): B = self.B degs = [self.ondeg(u) for u in range(B.n)] ones = [u for u in range(B.n) if degs[u] == 1] if len(ones) != 1 or any(d not in (1, 2) for d in degs): return False # the path from the gate must cover everything seen = {B.gate} stack = [B.gate] while stack: u = stack.pop() for e in B.inc[u]: if self.s[e] == 1: a, b = B.edges[e] w = b if a == u else a if w not in seen: seen.add(w) stack.append(w) return len(seen) == B.n def try_assume(st: State, e, val, depth): t = st.copy() try: t.set(e, val) t.propagate() if depth > 0: trial_loop(t, depth - 1, stats=None) if t.solved() and not t.valid_final(): raise Contradiction except Contradiction: return False return True def trial_loop(st: State, depth, stats): """Repeatedly refute edge assumptions at the given depth; mutates st.""" progress = True while progress and not st.solved(): progress = False for e in st.unknown_edges(): if st.s[e] != 0: continue for val in (1, -1): if not try_assume(st, e, val, depth): st.set(e, -val) st.propagate() if stats is not None: stats["trials"] += 1 progress = True break return st def grade(g: Garden, max_depth=2): """Return dict(tier=1..5, trials, solved). tier 5 = not solved by depth<=max_depth trials.""" B = Board(g) # tier 1: local rules only st = State(B, use_global=False) try: st.propagate() except Contradiction: return {"tier": 0, "trials": 0, "solved": False, "note": "contradiction (unsolvable)"} if st.solved() and st.valid_final(): return {"tier": 1, "trials": 0, "solved": True} st = State(B, use_global=True) try: st.propagate() except Contradiction: return {"tier": 0, "trials": 0, "solved": False, "note": "contradiction (unsolvable)"} if st.solved() and st.valid_final(): return {"tier": 2, "trials": 0, "solved": True} for depth in range(max_depth): stats = {"trials": 0} t = st.copy() try: trial_loop(t, depth, stats) except Contradiction: return {"tier": 0, "trials": stats["trials"], "solved": False, "note": "contradiction in trials"} if t.solved() and t.valid_final(): return {"tier": 3 + depth, "trials": stats["trials"], "solved": True} st = t return {"tier": 3 + max_depth, "trials": None, "solved": False} src/hints.py — The abbot's first observation for generated gardens, derived from solver facts (so true by construction). """hints.py: the abbot's first observation for generated gardens, derived from solver facts (so true by construction). The observation is chosen as the one fact that most changes the player's plan: free gardens: among facts true of every par raking (where it ends, how it begins, a whole row or column raked in one line, whether the line turns or runs straight in some cell), the one that the most near-misses (rakings one or two turns over par) get wrong; strict gardens: among facts about the unique raking (where the rake is set down; whether the line turns or runs straight in some cell), the one that lets the grader's local and global rules (tiers 1-2) decide the most of the garden, i.e. the step a player would otherwise need lookahead for. Each observation comes with the cells the abbot points at. Parity lessons: the colour count, and why the far corner fails. trap_score() measures how strongly a garden punishes the obvious move (see there). """ from __future__ import annotations import copy from collections import deque ORD = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth"] WORD = {"N": "straight up", "E": "right", "W": "left", "S": "down"} def _dir(a, b): return {(-1, 0): "N", (1, 0): "S", (0, 1): "E", (0, -1): "W"}[(b[0] - a[0], b[1] - a[1])] def row_phrase(g, r): if r == 0: return "in the top row" if r == g.H - 1: return "in the bottom row" return f"in the {ORD[r]} row from the top" if r < g.H / 2 else f"in the {ORD[g.H - 1 - r]} row from the bottom" def col_phrase(g, c): if c == 0: return "against the left wall" if c == g.W - 1: return "against the right wall" return f"{ORD[c]} from the left" if c < g.W / 2 else f"{ORD[g.W - 1 - c]} from the right" def gate_word(g): return "veranda gate" if g.end is not None else "gate" # with a side gate, "the gate" is ambiguous def describe(g, cell): r, c = cell gr, gc = g.gate[0], g.gate[1] if (r, c) == (gr, gc): return f"in the cell just inside the {gate_word(g)}" if r == gr and abs(c - gc) == 1: return f"right beside the {gate_word(g)}" corners = {(0, 0): "in the top-left corner", (0, g.W - 1): "in the top-right corner", (g.H - 1, 0): "in the bottom-left corner", (g.H - 1, g.W - 1): "in the bottom-right corner"} if (r, c) in corners: return corners[(r, c)] return f"{row_phrase(g, r)}, {col_phrase(g, c)}" def cell_noun(g, cell): """The cell as a noun phrase: 'the top-left corner', 'the cell just inside the gate', 'the cell in the top row, ...'.""" d = describe(g, cell) if d.startswith("in the cell"): return d[len("in "):] if d.startswith("in the ") and d.endswith(" corner"): return d[len("in "):] return "the cell " + d def one_way_in(g, cell): r, c = cell return (r, c) != (g.gate[0], g.gate[1]) and sum(g.is_sand(r + a, c + b) for a, b in ((-1, 0), (1, 0), (0, -1), (0, 1))) == 1 def features(g): s = "".join(g.rows) return {"wide": g.nohairpin == 2, "side": g.end is not None, "pebbles": bool(g.marks), "grooves": bool(g.clues), "ripple": "O" in s, "band": ("h" in s or "v" in s), "blocks": ("=" in s or "|" in s), "ring": "R" in s} def line_name(g, kind, i): if kind == "row": return "top row" if i == 0 else "bottom row" if i == g.H - 1 else ( f"{ORD[i]} row from the top" if i < g.H / 2 else f"{ORD[g.H - 1 - i]} row from the bottom") return "left-hand column" if i == 0 else "right-hand column" if i == g.W - 1 else ( f"{ORD[i]} column from the left" if i < g.W / 2 else f"{ORD[g.W - 1 - i]} column from the right") def common_line(g, par_rakings): """A whole row or column of plain sand (wall to wall) that every par raking rakes as one straight line.""" lines = [("row", r, [(r, c) for c in range(g.W)]) for r in range(g.H)] + \ [("column", c, [(r, c) for r in range(g.H)]) for c in range(g.W)] best = None for kind, i, cells in lines: if not all(g.rows[x[0]][x[1]] == "." for x in cells) or len(cells) < 4: # plain sand: a band row is straight anyway continue fwd, back = [tuple(x) for x in cells], [tuple(x) for x in reversed(cells)] n = len(cells) def has(d): d = [tuple(x) for x in d] return any(d[k:k + n] in (fwd, back) for k in range(len(d) - n + 1)) if all(has(d) for d in par_rakings) and (best is None or n > best[0]): best = (n, kind, i) if best is None: return None n, kind, i = best return f"Every par raking rakes the whole {line_name(g, kind, i)} in one straight line." def hint_free(g, par, par_rakings): ends = {tuple(d[-1]) for d in par_rakings} firsts = {_dir(d[0], d[1]) for d in par_rakings if len(d) > 1} gate = (g.gate[0], g.gate[1]) f = features(g) rings = "".join(g.rows).count("O") ring_txt = "Rake the ring in one run" if rings == 1 else "Rake each ring in one run" lead = (f"{ring_txt}, and cross each band in one straight run. " if f["ripple"] and f["band"] else f"{ring_txt}. " if f["ripple"] else "Cross each band in one straight run. " if f["band"] else "") if len(ends) == 1: return lead + f"At par, the walk ends {describe(g, next(iter(ends)))}." if all(abs(e[0] - gate[0]) + abs(e[1] - gate[1]) == 1 for e in ends): return lead + "At par, the walk ends right beside the gate." rows, cols = {e[0] for e in ends}, {e[1] for e in ends} if len(rows) == 1: return lead + f"At par, the walk ends {row_phrase(g, next(iter(rows)))}." if len(cols) == 1 and next(iter(cols)) in (0, g.W - 1): return lead + f"At par, the walk ends {col_phrase(g, next(iter(cols)))}." if len(firsts) == 1: return lead + f"Every par raking begins by walking {WORD[next(iter(firsts))]} from the gate." line = common_line(g, par_rakings) if line: return lead + line return lead + f"Par is {par} turns: long lines from wall to wall, and as few U-turns as the stones allow." def hint_strict(g, draw, all_draws=None): f = features(g) draws = all_draws or [draw] ends = {tuple(d[-1]) for d in draws} end_txt = f"the rake is set down {describe(g, next(iter(ends)))}" if len(ends) == 1 else None if f["side"]: heads = {_dir(d[0], d[1]) for d in draws if len(d) > 1} if len(heads) == 1: first = f"From the veranda gate, the walk first heads {WORD[next(iter(heads))]}." else: first = "Plan from both gates at once." tail = " Every reversal needs a pivot." if f["wide"] else " Settle the pebbles beside walls and stones first." if f["pebbles"] else "" return first + tail if f["blocks"]: lead = "Rake each block as parallel lines, then step into the next." elif f["pebbles"]: lead = "Settle the pebbles beside walls and stones first: there, often only one way through fits." elif f["grooves"]: lead = "Extend each of the abbot's grooves into its neighbours first." elif f["wide"]: lead = "Every reversal needs a pivot: a wall or a stone." else: lead = "Long lines first." if f["ring"] and not f["pebbles"]: lead += " Leave the finished ring as it is." return lead + (f" Here {end_txt}." if end_txt else "") def far_cell(g): start = (g.gate[0], g.gate[1]) dist, q = {start: 0}, deque([start]) while q: a = q.popleft() for d in ((1, 0), (-1, 0), (0, 1), (0, -1)): b = (a[0] + d[0], a[1] + d[1]) if g.is_sand(*b) and b not in dist: dist[b] = dist[a] + 1 q.append(b) H, W = g.H, g.W return max(dist, key=lambda c: (dist[c], c in ((0, 0), (0, W - 1), (H - 1, 0), (H - 1, W - 1)))) def hint_parity(g, all_draws): cells = g.sand_cells() dark = sum((r + c) % 2 == 0 for r, c in cells) light = len(cells) - dark gate_dark = (g.gate[0] + g.gate[1]) % 2 == 0 if dark == light + 1: rule, end_dark = "There is one more dark cell than light, so the walk ends on a dark cell", True elif light == dark + 1: rule, end_dark = "There is one more light cell than dark, so the walk ends on a light cell", False else: end_dark = not gate_dark rule = f"Dark and light cells are equal in number and the gate is on a {'dark' if gate_dark else 'light'} cell, so the walk ends on a {'dark' if end_dark else 'light'} one" far = far_cell(g) far_dark = (far[0] + far[1]) % 2 == 0 assert all(((d[-1][0] + d[-1][1]) % 2 == 0) == end_dark for d in all_draws), "parity rule contradicted by a raking" if far_dark != end_dark: assert all(tuple(d[-1]) != far for d in all_draws) return f"{rule}: not {describe(g, far)}, which is {'dark' if far_dark else 'light'}." return rule + "." # ---------------------------------------------------------------- the observation that changes the plan STRAIGHT_IN = {"S": "N", "N": "S", "E": "W", "W": "E"} # the obvious first move: straight in through the gate ZONE = set("hv=|") def first_dir(d): return _dir(d[0], d[1]) if len(d) > 1 else None def shapes(g, d): """Cell -> 'S' (the line runs straight through it) or 'B' (it turns there), along a walk from the gate; the last cell (where the rake is set down) has no shape.""" d = [tuple(c) for c in d] out = {} for i, c in enumerate(d[:-1]): din = STRAIGHT_IN[g.gate[2]] if i == 0 else _dir(d[i - 1], c) out[c] = "S" if _dir(c, d[i + 1]) == din else "B" return out def near_misses(g, par, slack=2, cap=300000): """Rakings within `slack` turns over par (the plans a player is most likely to find instead of par).""" from zg import turns_of r = g.solve(mode=2, bound=par + slack, cap=cap, stream=True) return [s_["draw"] for s_ in r["sols"] if turns_of(g, s_["draw"]) > par] def _beside_gate(g): gr, gc, side = g.gate return {(gr, gc - 1), (gr, gc + 1)} if side in "NS" else {(gr - 1, gc), (gr + 1, gc)} def free_facts(g, par_rakings): """Facts true of every par raking: (kind rank, text, cells pointed at, test on a raking).""" P = [[tuple(c) for c in d] for d in par_rakings] gate = (g.gate[0], g.gate[1]) F = [] ends = {d[-1] for d in P} if len(ends) == 1: e = next(iter(ends)) F.append((0, f"At par, the walk ends {describe(g, e)}.", [e], lambda d, e=e: tuple(d[-1]) == e)) else: bg = _beside_gate(g) if ends <= bg: F.append((0, "At par, the walk ends right beside the gate.", sorted(ends), lambda d, bg=bg: tuple(d[-1]) in bg)) rows, cols = {e[0] for e in ends}, {e[1] for e in ends} if len(rows) == 1: r = next(iter(rows)) F.append((1, f"At par, the walk ends in the {line_name(g, 'row', r)}.", sorted(ends), lambda d, r=r: d[-1][0] == r)) if len(cols) == 1: c = next(iter(cols)) F.append((1, f"At par, the walk ends in the {line_name(g, 'column', c)}.", sorted(ends), lambda d, c=c: d[-1][1] == c)) firsts = {first_dir(d) for d in P} if len(firsts) == 1: f = next(iter(firsts)) nxt = P[0][1] F.append((0, f"Every par raking begins by walking {WORD[f]} from the gate.", [gate, nxt], lambda d, f=f: first_dir(d) == f)) for kind, i, cells in [("row", r, [(r, c) for c in range(g.W)]) for r in range(g.H)] + \ [("column", c, [(r, c) for r in range(g.H)]) for c in range(g.W)]: if len(cells) < 4 or not all(g.rows[x[0]][x[1]] == "." for x in cells): continue fwd, back, n = list(cells), list(reversed(cells)), len(cells) def has(d, fwd=fwd, back=back, n=n): d = [tuple(x) for x in d] return any(d[k:k + n] in (fwd, back) for k in range(len(d) - n + 1)) if all(has(d) for d in P): F.append((1, f"Every par raking rakes the whole {line_name(g, kind, i)} in one straight line.", cells, has)) # a stretch of a row or column between stones or walls (not the whole line), raked in one straight run at par for kind, i, cells in [("row", r, [(r, c) for c in range(g.W)]) for r in range(g.H)] + \ [("column", c, [(r, c) for r in range(g.H)]) for c in range(g.W)]: runs, cur = [], [] for x in cells: if g.rows[x[0]][x[1]] == ".": cur.append(x) else: runs.append(cur); cur = [] runs.append(cur) for seg in runs: if len(seg) < 3 or len(seg) == len(cells): continue fwd, back, n = list(seg), list(reversed(seg)), len(seg) def has(d, fwd=fwd, back=back, n=n): d = [tuple(x) for x in d] return any(d[k:k + n] in (fwd, back) for k in range(len(d) - n + 1)) if all(has(d) for d in P): F.append((1, f"Every par raking rakes the marked stretch of the {line_name(g, kind, i)} in one straight line.", seg, has)) S = [shapes(g, d) for d in P] for c in sorted(set.intersection(*[set(x) for x in S])): if g.rows[c[0]][c[1]] in ZONE or c in g.marks or c in g.clues: continue t = {x[c] for x in S} if len(t) == 1: t = next(iter(t)) text = (f"At par, the line turns at {cell_noun(g, c)}." if t == "B" else f"At par, the line runs straight through {cell_noun(g, c)}.") F.append((2, text, [c], lambda d, c=c, t=t: shapes(g, d).get(c) == t)) return F def best_free_hint(g, par, par_rakings, near=None): """The fact true of every par raking that the most near-misses get wrong (ties: the more natural kind of fact).""" F = free_facts(g, par_rakings) if not F: return f"Par is {par} turns: long lines from wall to wall, and as few U-turns as the stones allow.", [], None near = near_misses(g, par) if near is None else near scored = [] for rank, text, cells, test in F: assert all(test(d) for d in par_rakings), text # true of every par raking, by construction miss = sum(not test(d) for d in near) / len(near) if near else 0.0 scored.append((miss - 0.04 * rank, -rank, text, cells, miss)) scored.sort(key=lambda x: (x[0], x[1]), reverse=True) _, _, text, cells, miss = scored[0] return text, [list(c) for c in cells], round(miss, 3) def _decided(B, end=None, mark=None): """How much of the garden the grader's tier 1-2 rules decide, given one more fact; and whether that solves it.""" from grader import State, Contradiction B2 = copy.copy(B) if end is not None: B2.cand0 = {B.idx[end]} if mark is not None: c, t = mark B2.marks = dict(B.marks) B2.marks[B.idx[c]] = t B2.cand0 = set(B.cand0) - {B.idx[c]} st = State(B2, use_global=True) try: st.propagate() except Contradiction: raise AssertionError("a true fact led to a contradiction") return sum(1 for x in st.s if x != 0), st.solved() and st.valid_final() def with_fact(g, cells, text): """The garden with a strict hint's fact added as a given: the end cell fixed, or a pebble at the cell.""" g2 = copy.deepcopy(g) c = tuple(cells[0]) if "rake is set down" in text: g2.end = c else: g2.marks = dict(g.marks); g2.marks[c] = "B" if "turns" in text else "S" return g2 def best_strict_hint(g, draw, regrade=8): """The fact about the unique raking that most lowers the garden's grade (tier, then lookahead steps), among the end cell and the `regrade` facts that let the tier 1-2 rules decide the most; ties: more decided, then where the rake is set down, then the cell earliest in the walk.""" from grader import Board, grade B = Board(g) d = [tuple(c) for c in draw] base, _ = _decided(B) cands = [] if g.end is None: e = d[-1] n, solved = _decided(B, end=e) # say why, when the reason is short: a cell with one way in is where the walk must end noun = cell_noun(g, e) noun = noun[0].upper() + noun[1:] + ("," if "," in noun else "") text = (f"{noun} has only one way in, so the rake is set down there." if one_way_in(g, e) else f"The rake is set down {describe(g, e)}.") cands.append([solved, n, 1, 0, text, [e]]) sh = shapes(g, d) for i, c in enumerate(d[:-1]): if g.rows[c[0]][c[1]] in ZONE or c in g.marks or c in g.clues: continue n, solved = _decided(B, mark=(c, sh[c])) text = (f"The line turns at {cell_noun(g, c)}." if sh[c] == "B" else f"The line runs straight through {cell_noun(g, c)}.") cands.append([solved, n, 0, -i, text, [c]]) if not cands: return "Follow the hatching: every block is raked in parallel lines.", [], (base, base) cands.sort(key=lambda x: x[:4], reverse=True) short = cands[:regrade] + [c for c in cands[regrade:] if c[2] == 1] best = None for c in short: gr = grade(with_fact(g, c[5], c[4])) assert gr["solved"], c[4] key = (gr["tier"], gr["trials"] or 0, -c[1], -c[2], -c[3]) if best is None or key < best[0]: best = (key, c) _, (solved, n, _, _, text, cells) = best return text, [list(c) for c in cells], (base, n) def trap_score(g, par, par_rakings=None, near=None, draw=None): """How strongly the garden punishes the obvious move (0 = not at all). Free: the share of near-misses (rakings one or two turns over par) that get the garden's key observation wrong (the best_free_hint fact), counted only when it is most of them: the plan that comes naturally is wrong there. (The first move is no use here: in almost every free garden, the near-misses' favourite first move is also a par move.) Strict: 1 when the only raking turns aside at once, but walking straight in through the gate is not refuted by the tier 1-2 rules (it takes lookahead to see why it fails).""" if draw is None: if not near: return 0.0 _, _, miss = best_free_hint(g, par, par_rakings, near) return miss if miss >= 0.5 else 0.0 obvious = STRAIGHT_IN[g.gate[2]] gr, gc = g.gate[0], g.gate[1] nxt = {"N": (gr - 1, gc), "S": (gr + 1, gc), "E": (gr, gc + 1), "W": (gr, gc - 1)}[obvious] if not g.is_sand(*nxt) or first_dir(draw) == obvious: return 0.0 from grader import Board, State, Contradiction B = Board(g) st = State(B, use_global=True) st.propagate() e = B.dir_edge[B.idx[(gr, gc)]].get(obvious) if e is None: return 0.0 try: t = st.copy(); t.set(e, 1); t.propagate() except Contradiction: return 0.0 return 1.0 src/levels.py — Search for example gardens per mechanic, verify them, and save to JSON. """levels.py: search for example gardens per mechanic, verify them, and save to JSON. Every saved level records: layout, rules, the number of valid rakings (exact, or a lower bound if capped), par (proven by CP-SAT), whether par is achievable 'gracefully' (no hairpin turn in open sand), the intended raking, and the deductive grade.""" from __future__ import annotations import json import random import sys import time from copy import deepcopy from zg import Garden, glyphs, hairpin_list, turns_of, check_solution from zcp import optimize from gen import random_layout, parity_ok from clues import make_unique, with_clues from grader import grade OUT = "/home/claude/zen/out/levels.json" def to_dict(g: Garden): return {"rows": g.rows, "gate": list(g.gate), "clues": {f"{r},{c}": v for (r, c), v in g.clues.items()}, "end": list(g.end) if g.end else None, "nohairpin": g.nohairpin, "braced_start": g.braced_start, "name": g.name, "end_side": g.end_side, "marks": {f"{r},{c}": t for (r, c), t in g.marks.items()}} def from_dict(d) -> Garden: g = Garden(d["rows"], tuple(d["gate"]), clues={tuple(map(int, k.split(","))): v for k, v in d["clues"].items()}, end=tuple(d["end"]) if d["end"] else None, nohairpin=d["nohairpin"], braced_start=d["braced_start"], name=d.get("name", ""), end_side=d.get("end_side"), marks={tuple(map(int, k.split(","))): v for k, v in d.get("marks", {}).items()}) return g def par_info(g: Garden, time_limit=120): free = optimize(g, time_limit=time_limit) if free[1] != "OPTIMAL": return None grace = optimize(g, nohairpin=2, time_limit=time_limit) return {"par": free[0], "par_draw": list(reversed(free[2])), "graceful_par": grace[0] if grace[1] == "OPTIMAL" else None, "graceful_draw": list(reversed(grace[2])) if grace[2] else None} def summarize(g: Garden, intended_draw=None, cap=10**7): r = g.solve(mode=0, cap=cap, nodelimit=2 * 10**9) info = {"n_rakings": r["nsol"], "count_exact": bool(r["complete"] and not r["capped"]), "hist": r["hist"]} p = par_info(g) if p: info.update({k: v for k, v in p.items() if not k.endswith("draw")}) info["at_par"] = r["hist"].get(p["par"], 0) if info["count_exact"] else None if intended_draw is None and p: intended_draw = p["graceful_draw"] if p["graceful_par"] == p["par"] else p["par_draw"] if intended_draw is not None: assert check_solution(g, intended_draw), "intended raking fails the rules" info["intended"] = [list(c) for c in intended_draw] info["intended_turns"] = turns_of(g, intended_draw) info["intended_open_hairpins"] = sum(1 for *_, br in hairpin_list(g, intended_draw) if not br) info["grade"] = grade(g) if r["nsol"] == 1 else None return info def graceful(g, info): return info.get("graceful_par") is not None and info["graceful_par"] == info["par"] # ---------------------------------------------------------------- searches def search_first_stroke(rng, budget=60): t0 = time.time() best = None while time.time() - t0 < budget: g = random_layout(6, 6, 2, rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0: continue p = par_info(g, 30) if not p or p["graceful_par"] != p["par"]: continue g.name = "First stroke" return g return best def search_ripple(rng, budget=120): t0 = time.time() while time.time() - t0 < budget: H, W = 7, 7 rows = [["."] * W for _ in range(H)] r, c = rng.randint(2, H - 3), rng.randint(2, W - 3) rows[r][c] = "O" for _ in range(rng.randint(1, 2)): a, b = rng.randrange(H), rng.randrange(W) if rows[a][b] == "." and max(abs(a - r), abs(b - c)) > 2: rows[a][b] = "#" gc = rng.choice([x for x in range(W) if rows[H - 1][x] == "."]) g = Garden(["".join(x) for x in rows], (H - 1, gc, "S"), name="Ripples") if not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0: continue p = par_info(g, 30) if p and p["graceful_par"] == p["par"]: return g return None def search_waves(rng, budget=120): t0 = time.time() while time.time() - t0 < budget: H, W = 7, 8 rows = [["."] * W for _ in range(H)] r0 = rng.randint(1, 2) for r in range(r0, r0 + 2): for c in range(1, W - 1): rows[r][c] = "h" for _ in range(rng.randint(1, 2)): a, b = rng.randint(r0 + 3, H - 2), rng.randrange(1, W - 1) rows[a][b] = "#" gc = rng.choice([x for x in range(W) if rows[H - 1][x] == "."]) g = Garden(["".join(x) for x in rows], (H - 1, gc, "S"), name="Waves") if not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0: continue p = par_info(g, 30) if p and p["graceful_par"] == p["par"]: return g return None def ichimatsu_garden(): rows = ["===|||", "===|||", "===|||", "|||===", "|||===", "|||==="] return Garden(rows, (5, 0, "S"), name="Ichimatsu") def search_grooves(rng, budget=300, size=(7, 7), stones=(2, 3), min_tier=3, max_clues=6, name="Master's grooves", extra=None): t0 = time.time() cands = [] while time.time() - t0 < budget: g = random_layout(size[0], size[1], rng.choice(stones), rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g): continue if extra: extra(g, rng) if g.solve(mode=0, cap=1)["nsol"] == 0: continue p = par_info(g, 30) if not p or p["graceful_par"] != p["par"]: continue target = p["graceful_draw"] clues = make_unique(g, target, rng) if clues is None or len(clues) > max_clues: continue gu = with_clues(g, clues) gr = grade(gu) if gr["solved"] and gr["tier"] >= min_tier: gu.name = name return gu, target, gr cands.append((gr["tier"], gu, target, gr)) if cands: cands.sort(key=lambda x: -x[0]) _, gu, target, gr = cands[0] gu.name = name return gu, target, gr return None def search_wide(rng, budget=300, size=(7, 7)): t0 = time.time() best = None while time.time() - t0 < budget: g = random_layout(size[0], size[1], rng.choice([2, 3]), rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g): continue g.nohairpin = 2 g.braced_start = True r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue gr = grade(g) if not gr["solved"]: continue key = (gr["tier"], gr["trials"] or 0) if best is None or key > best[0]: g.name = "Wide rake" best = (key, g, r["sols"][0]["draw"], gr) if gr["tier"] >= 3 and (gr["trials"] or 0) >= 8: break return best def search_two_gates(rng, budget=300): t0 = time.time() while time.time() - t0 < budget: g = random_layout(7, 7, rng.choice([2, 3]), rng, gate_side="S", min_gap=1) if g is None: continue # side gate on the east wall: the rake must start there er = rng.randint(0, 4) if g.rows[er][6] != ".": continue g.end = (er, 6) r = g.solve(mode=0, cap=3) if r["nsol"] != 1: continue gr = grade(g) if gr["solved"] and gr["tier"] >= 2: g.name = "Two gates" return g, r["sols"][0]["draw"], gr return None def main(): seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1 rng = random.Random(seed) levels = {} g1 = Garden(["........", "........", "..#.....", "........", ".....#..", "........", "........"], (6, 3, "S"), name="Calm and agitated") levels["calm"] = {"garden": to_dict(g1), **summarize(g1)} print("calm", {k: v for k, v in levels["calm"].items() if k not in ("garden", "intended", "hist")}, flush=True) g = search_first_stroke(rng) levels["first"] = {"garden": to_dict(g), **summarize(g)} print("first", {k: v for k, v in levels["first"].items() if k not in ("garden", "intended", "hist")}, flush=True) g = search_ripple(rng) levels["ripple"] = {"garden": to_dict(g), **summarize(g)} print("ripple", {k: v for k, v in levels["ripple"].items() if k not in ("garden", "intended", "hist")}, flush=True) g = search_waves(rng) levels["waves"] = {"garden": to_dict(g), **summarize(g)} print("waves", {k: v for k, v in levels["waves"].items() if k not in ("garden", "intended", "hist")}, flush=True) g = ichimatsu_garden() levels["ichimatsu"] = {"garden": to_dict(g), **summarize(g)} print("ichimatsu", {k: v for k, v in levels["ichimatsu"].items() if k not in ("garden", "intended", "hist")}, flush=True) res = search_grooves(rng) if res: gu, target, gr = res levels["grooves"] = {"garden": to_dict(gu), **summarize(gu, intended_draw=target)} print("grooves", {k: v for k, v in levels["grooves"].items() if k not in ("garden", "intended", "hist")}, flush=True) res = search_wide(rng) if res: _, g, draw, gr = res levels["wide"] = {"garden": to_dict(g), **summarize(g, intended_draw=draw)} print("wide", {k: v for k, v in levels["wide"].items() if k not in ("garden", "intended", "hist")}, flush=True) res = search_two_gates(rng) if res: g, draw, gr = res levels["twogates"] = {"garden": to_dict(g), **summarize(g, intended_draw=draw)} print("twogates", {k: v for k, v in levels["twogates"].items() if k not in ("garden", "intended", "hist")}, flush=True) with open(OUT, "w") as f: json.dump(levels, f, indent=1) print("saved", OUT) if __name__ == "__main__": main() src/pairs.py — Contrasting pairs that introduce each rule: the same small garden twice, before and after one change. """pairs.py: contrasting pairs that introduce each rule: the same small garden twice, before and after one change. Each search samples small layouts and keeps the best pair it finds in a time budget. A pair is kept only if the change matters: the old way of raking the first garden must fail in the second (ripple stone, band, wide rake), or the second must be pinned down to one raking by a few marks (grooves; pebbles in place of grooves), or the side gate must change the answer. Smaller and easier pairs are preferred. """ from __future__ import annotations import copy import math import random import time from zg import Garden, check_solution, hairpin_list, turns_of, glyphs from gen import parity_ok from garden_sampler import add_ripple, add_band, add_stones, add_closed_ring, add_side_gate, garden_layout from clues import make_unique, with_clues, make_unique_marks, with_marks from grader import grade from zcp import optimize SIZES = [(4, 5), (5, 4), (5, 5), (4, 6), (5, 6)] def counted(g, cap=10**6): r = g.solve(mode=0, cap=cap) return r if (r["complete"] and not r["capped"]) else None def par_rakings(g, par): return [s["draw"] for s in g.solve(mode=2, bound=par, cap=10**5, stream=True)["sols"]] def open_u_turn(g, d): return any(not braced for *_, braced in hairpin_list(g, d)) def bottom_gate(rows, rng): H = len(rows) cand = [c for c in range(len(rows[0])) if rows[H - 1][c] == "."] return (H - 1, rng.choice(cand), "S") if cand else None def as_rows(rows): return ["".join(r) for r in rows] def habit_fails(A, B): """Every par raking of A breaks B's rule: the plan that worked before no longer does.""" ra, rb = counted(A), counted(B) if ra is None or rb is None or ra["nsol"] < 3 or rb["nsol"] < 1: return None pa = min(ra["hist"]) PA = par_rakings(A, pa) if any(check_solution(B, d) for d in PA): return None return ra, rb, pa def search(make, budget, rng): best, t0, tried = None, time.time(), 0 while time.time() - t0 < budget: tried += 1 c = make(rng) if c is not None and (best is None or c["score"] < best["score"]): best = c if best: best["tried"] = tried return best # ---------------------------------------------------------------- the pairs def graceful_par(g): """Some par raking has no U-turn in open sand (the generator's filter for free gardens: the seal should not reward only meanders).""" free, grace = optimize(g, time_limit=20), optimize(g, nohairpin=2, time_limit=20) return free[1] == "OPTIMAL" and grace[1] == "OPTIMAL" and grace[0] == free[0] def make_ripple(rng): H, W = rng.choice(SIZES) rows = [["."] * W for _ in range(H)] if not add_ripple(rows, rng, H, W): return None if rng.random() < 0.5 and not add_stones(rows, rng, H, W, 1, max_frac=0.12): return None gate = bottom_gate(rows, rng) if gate is None: return None B = Garden(as_rows(rows), gate) A = Garden([r.replace("O", "#") for r in B.rows], gate) if not parity_ok(B): return None h = habit_fails(A, B) if h is None or h[1]["nsol"] < 2 or not (graceful_par(A) and graceful_par(B)): return None return {"A": A, "B": B, "score": (len(B.sand_cells()), h[1]["nsol"]), "facts": {"A_rakings": h[0]["nsol"], "B_rakings": h[1]["nsol"]}} def make_band(rng): H, W = rng.choice([(5, 5), (5, 6), (4, 6), (6, 5)]) rows = [["."] * W for _ in range(H)] if rng.random() < 0.6 and not add_stones(rows, rng, H, W, 1, max_frac=0.12): return None plain = [r[:] for r in rows] sym = "h" if H < 5 else "v" if W < 5 else rng.choice("hv") # (a band needs room for at least three cells) if not add_band(rows, rng, H, W, sym): return None gate = bottom_gate(rows, rng) if gate is None: return None A, B = Garden(as_rows(plain), gate), Garden(as_rows(rows), gate) if not parity_ok(A): return None h = habit_fails(A, B) if h is None or h[1]["nsol"] < 2 or not (graceful_par(A) and graceful_par(B)): return None return {"A": A, "B": B, "score": (len(B.sand_cells()), h[1]["nsol"]), "facts": {"A_rakings": h[0]["nsol"], "B_rakings": h[1]["nsol"]}} def graceful_target(g): free, grace = optimize(g, time_limit=20), optimize(g, nohairpin=2, time_limit=20) if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: return None return list(reversed(grace[2])) def make_grooves(rng, max_clues=3): H, W = rng.choice([(5, 5), (5, 6), (6, 5), (6, 6)]) A = garden_layout(H, W, rng, n_pieces=1, max_frac=0.12) if A is None or not parity_ok(A): return None ra = counted(A) if ra is None or ra["nsol"] < 8: return None target = graceful_target(A) if target is None: return None cl = make_unique(A, target, rng) if cl is None or len(cl) > max_clues: return None B = with_clues(A, cl) gr = grade(B) if not gr["solved"] or gr["tier"] > 2: return None return {"A": A, "B": B, "target": target, "score": (len(cl), gr["tier"], len(B.sand_cells())), "facts": {"A_rakings": ra["nsol"], "grooves": len(cl), "tier": gr["tier"]}} def make_pebbles(rng): """A: a garden pinned down by a few grooves. B: the same garden, with a pebble in each groove's cell (it only says straight or turn), plus whatever pebbles are needed for one raking again.""" c = make_grooves(rng, max_clues=3) if c is None: return None A, target = c["B"], c["target"] base = copy.deepcopy(A); base.clues = {} gl = glyphs(base, target) marks0 = {cell: ("S" if set(gl[cell]) in ({"E", "W"}, {"N", "S"}) else "B") for cell in A.clues} B0 = with_marks(base, marks0) extra = make_unique_marks(B0, target, rng) if extra is None or len(marks0) + len(extra) > 6: return None B = with_marks(B0, extra) gr = grade(B) if not gr["solved"] or gr["tier"] > 2: return None kinds = set(B.marks.values()) # an introduction should show both kinds of pebble return {"A": A, "B": B, "score": (len(kinds) < 2, len(extra), gr["tier"], len(B.sand_cells())), "facts": {"grooves": len(A.clues), "pebbles": len(B.marks), "kinds": len(kinds), "tier": gr["tier"]}} def make_wide(rng): """A: an ordinary garden whose every par raking turns back in open sand. B: the same garden with the wide rake.""" H, W = rng.choice([(4, 5), (5, 4), (5, 5), (5, 6), (4, 6)]) A = garden_layout(H, W, rng, n_pieces=1, max_frac=0.12) if A is None or not parity_ok(A): return None ra = counted(A) if ra is None or ra["nsol"] < 3: return None pa = min(ra["hist"]) if not all(open_u_turn(A, d) for d in par_rakings(A, pa)): return None B = copy.deepcopy(A); B.nohairpin = 2; B.braced_start = True rb = B.solve(mode=0, cap=2) if rb["nsol"] != 1: return None gr = grade(B) if not gr["solved"] or gr["tier"] > 3: return None return {"A": A, "B": B, "score": (gr["tier"], gr["trials"] or 0, len(B.sand_cells())), "facts": {"A_rakings": ra["nsol"], "A_par": pa, "tier": gr["tier"]}} def side_options(g): """Every cell of the north, east or west wall where a side gate could go (not the veranda gate's cell).""" out = [((0, c), "N") for c in range(g.W) if g.rows[0][c] == "."] out += [((r, 0), "W") for r in range(g.H - 1) if g.rows[r][0] == "."] out += [((r, g.W - 1), "E") for r in range(g.H - 1) if g.rows[r][g.W - 1] == "."] return [o for o in out if o[0] != (g.gate[0], g.gate[1])] def make_side(rng): """A: a small garden for the wide rake with a few rakings. B: the same garden with a side gate, which leaves exactly one of them, the one that begins there. (B's rakings are always among A's: the side gate's cell touches a wall, so a raking that begins there is a valid raking of A. So A must have more than one.) Kept only if B's raking is not a par raking of A: the plan that was best before has to change.""" H, W = rng.choice([(5, 5), (5, 6), (6, 5), (6, 6)]) A = garden_layout(H, W, rng, n_pieces=1, max_frac=0.12) if A is None: return None A.nohairpin = 2; A.braced_start = True ra = A.solve(mode=0, cap=10**4) if not ra["complete"] or ra["capped"] or not (2 <= ra["nsol"] <= 8): return None pa = min(ra["hist"]) best = None for cell, side in side_options(A): B = copy.deepcopy(A); B.end, B.end_side = cell, side rb = B.solve(mode=0, cap=2, stream=True) if rb["nsol"] != 1: continue d = rb["sols"][0]["draw"] if turns_of(A, d) == pa: continue gb = grade(B) if not gb["solved"] or gb["tier"] > 4: continue c = {"A": A, "B": B, "score": (gb["tier"], gb["trials"] or 0, len(B.sand_cells()), ra["nsol"]), "facts": {"A_rakings": ra["nsol"], "A_par": pa, "B_turns": turns_of(B, d), "B_tier": gb["tier"]}} if best is None or c["score"] < best["score"]: best = c return best def make_ring(rng): """A small first garden with a ring the abbot has finished, pinned down by at most two grooves.""" H, W = rng.choice([(5, 6), (6, 5), (6, 6)]) g = garden_layout(H, W, rng, closed_ring=True, n_pieces=1, max_frac=0.1) if g is None or not parity_ok(g): return None if counted(g) is None: return None target = graceful_target(g) if target is None: return None cl = make_unique(g, target, rng) if cl is None or len(cl) > 2: return None B = with_clues(g, cl) gr = grade(B) if not gr["solved"] or gr["tier"] > 2: return None return {"B": B, "score": (len(cl), gr["tier"], len(B.sand_cells())), "facts": {"grooves": len(cl), "tier": gr["tier"]}} MAKERS = {"ripple": make_ripple, "band": make_band, "grooves": make_grooves, "pebbles": make_pebbles, "wide": make_wide, "side": make_side, "ring": make_ring} BUDGET = {"ripple": 120, "band": 120, "grooves": 120, "pebbles": 180, "wide": 240, "side": 420, "ring": 120} def run(args): name, seed, budget = args return name, search(MAKERS[name], budget or BUDGET[name], random.Random(seed)) src/poems.py — The poems: one classical waka or haiku for each garden, and one for the practice garden, shown when the garden is """The poems: one classical waka or haiku for each garden, and one for the practice garden, shown when the garden is raked. poems/samon-poems.json is the curated selection (see its notes); attach() gives each garden the compact form the page embeds, and returns the practice garden's.""" import json PATH = '/home/claude/zen/poems/samon-poems.json' def load(): return json.load(open(PATH)) def compact(p): src = p.get("display_source") if src is None: src = " ".join(str(x) for x in (p.get("collection"), p.get("poem_number")) if x) return {"en": p["translation"], "ja": p["japanese"], "poet": p["poet"], "poet_ja": p.get("author_japanese", ""), "dates": p.get("author_dates", ""), "src": src, "url": p.get("source_url") or ""} def attach(gardens): D = load() missing = [g["name"] for g in gardens if g["name"] not in D["gardens"]] assert not missing, f"gardens without a poem: {missing}" for g in gardens: p = D["poems"][D["gardens"][g["name"]]] assert p["translation"].strip() and p["japanese"].strip(), g["name"] g["poem"] = compact(p) return compact(D["poems"][D["practice"]]) src/preview.py import json, sys from levels import from_dict from render import garden_svg, hstack, save L = json.load(open('/home/claude/zen/out/levels.json')) for key, lv in L.items(): g = from_dict(lv['garden']) draw = [tuple(c) for c in lv['intended']] sv = hstack([garden_svg(g, label=f"{key}: puzzle", seed=3), garden_svg(g, draw=draw, label=f"{lv['intended_turns']} turns (par {lv['par']}), {lv['n_rakings']} rakings", seed=3)]) save(sv, f"/home/claude/zen/fig/prev_{key}.png", scale=1.0) print("done") src/render.py — SVG/PNG figures of gardens, puzzles and rakings (no external drawing libraries). """render.py: SVG/PNG figures of gardens, puzzles and rakings (no external drawing libraries).""" from __future__ import annotations import math import random from zg import Garden, glyphs, DIRS, DR, DC, OPP S = 44 # cell size (px) WALL = 14 # wall thickness TINES = (0.2, 0.5, 0.8) SAND = "#eeefec" SAND_DOT = "#dcdedb" GROOVE = "#8e928d" GROOVE_HI = "#ffffff" STONE = "#4a4d4b" STONE_HI = "#6b6f6c" WALLC = "#2f3130" INK = "#1d1f1e" SEAL = "#c23b22" MARK = "#5b7a99" # puzzle markings (bands, rings) in a cool slate blue def _xy(r, c, ox, oy): return ox + c * S, oy + r * S def _stone_path(cx, cy, rad, rng, sides=9): pts = [] phase = rng.random() * math.tau for i in range(sides): a = phase + i * math.tau / sides rr = rad * (0.78 + 0.3 * rng.random()) pts.append((cx + rr * math.cos(a), cy + rr * math.sin(a) * 0.86)) # smooth closed path through midpoints (quadratic) d = "" n = len(pts) for i in range(n): p0, p1 = pts[i], pts[(i + 1) % n] mid = ((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2) if i == 0: pm = ((pts[-1][0] + p0[0]) / 2, (pts[-1][1] + p0[1]) / 2) d += f"M{pm[0]:.1f},{pm[1]:.1f} " d += f"Q{p0[0]:.1f},{p0[1]:.1f} {mid[0]:.1f},{mid[1]:.1f} " return d + "Z" def _components(g: Garden, sym_set): """Connected components (4-neighbour) of blocked cells, for drawing multi-cell stones.""" seen = set() comps = [] for r in range(g.H): for c in range(g.W): if g.rows[r][c] in sym_set and (r, c) not in seen: comp = [] stack = [(r, c)] seen.add((r, c)) while stack: a, b = stack.pop() comp.append((a, b)) for d in DIRS: nb = (a + DR[d], b + DC[d]) if 0 <= nb[0] < g.H and 0 <= nb[1] < g.W and g.rows[nb[0]][nb[1]] == g.rows[r][c] and nb not in seen: seen.add(nb) stack.append(nb) comps.append(comp) return comps def _groove_cell(x, y, dirs, start=False, cls_color=GROOVE): """SVG path data for the tines through one cell; dirs is a set of 1-2 directions.""" out = [] dirs = set(dirs) if len(dirs) == 2 and dirs in ({"E", "W"}, {"N", "S"}): for o in TINES: if dirs == {"E", "W"}: out.append(f"M{x:.1f},{y + o * S:.1f} H{x + S:.1f}") else: out.append(f"M{x + o * S:.1f},{y:.1f} V{y + S:.1f}") elif len(dirs) == 2: # corner: arc centred on the cell corner shared by the two sides cx = x + (S if "E" in dirs else 0) cy = y + (S if "S" in dirs else 0) for o in TINES: rad = o * S if True else 0 # point on first side and second side pts = [] for d in sorted(dirs): if d in "NS": yy = y + (0 if d == "N" else S) xx = cx + (-rad if cx > x else rad) pts.append((xx, yy)) else: xx = x + (0 if d == "W" else S) yy = cy + (-rad if cy > y else rad) pts.append((xx, yy)) (x1, y1), (x2, y2) = pts out.append(f"M{x1:.1f},{y1:.1f} A{rad:.1f},{rad:.1f} 0 0 {_sweep(x1, y1, x2, y2, cx, cy)} {x2:.1f},{y2:.1f}") else: (d,) = tuple(dirs) # rake start: tines run from the edge in direction d back to just past the centre for o in TINES: if d in "EW": x_edge = x + (S if d == "E" else 0) x_end = x + (0.22 * S if d == "E" else 0.78 * S) out.append(f"M{x_edge:.1f},{y + o * S:.1f} H{x_end:.1f}") else: y_edge = y + (S if d == "S" else 0) y_end = y + (0.22 * S if d == "S" else 0.78 * S) out.append(f"M{x + o * S:.1f},{y_edge:.1f} V{y_end:.1f}") return out def _sweep(x1, y1, x2, y2, cx, cy): # choose the short arc: sign of cross product of (p1-c) x (p2-c) cr = (x1 - cx) * (y2 - cy) - (y1 - cy) * (x2 - cx) return 1 if cr > 0 else 0 def garden_svg(g: Garden, draw=None, show_marks=True, show_clue_grooves=True, title=None, footprints=None, seed=1, label=None, sublabel=None, highlight_start=True, show_gate_arrow=True, ghost_path=None, glyph_override=None, marks_svg=None, checker=False, monk=None, show_gate=True): """Return an SVG string for one garden panel. draw: solution in drawing order (gate first) -> rendered as raked grooves. footprints: partial path (drawing order) rendered as footprints.""" rng = random.Random(seed) top = 30 if title else 8 bottom = 46 if (label or sublabel) else 16 ox, oy = WALL + 10, top + WALL width = g.W * S + 2 * WALL + 20 height = g.H * S + 2 * WALL + top + bottom el = [] el.append(f'') el.append(f'') if title: el.append(f'{title}') # wall el.append(f'') # sand (one rectangle: per-cell rects leave hairline seams when rasterised at fractional scales) el.append(f'') # unraked texture: sparse dots on cells without grooves raked = set() gl = {} if draw: gl = glyphs(g, draw) side = getattr(g, "end_side", None) if side and g.end is not None and tuple(draw[-1]) == tuple(g.end): gl[tuple(g.end)] = gl[tuple(g.end)] + side # the stroke enters through the side gate raked = set(gl) if glyph_override is not None: gl = dict(glyph_override) raked = set(gl) if checker: for r in range(g.H): for c in range(g.W): if (r + c) % 2 == 0 and g.is_sand(r, c): x, y = _xy(r, c, ox, oy) el.append(f'') if show_clue_grooves: for cell, dirs in g.clues.items(): raked.add(cell) for r in range(g.H): for c in range(g.W): if g.is_sand(r, c) and (r, c) not in raked: x, y = _xy(r, c, ox, oy) for _ in range(5): el.append(f'') # puzzle marks: bands / ichimatsu hatching if show_marks and not draw: for r in range(g.H): for c in range(g.W): s = g.rows[r][c] x, y = _xy(r, c, ox, oy) if s in "h=": for o in (0.25, 0.5, 0.75): el.append(f'') elif s in "v|": for o in (0.25, 0.5, 0.75): el.append(f'') # band outlines for comp in _components(g, set("hv=|")): cs = set(comp) for (r, c) in comp: x, y = _xy(r, c, ox, oy) for d, (x1, y1, x2, y2) in {"N": (x, y, x + S, y), "S": (x, y + S, x + S, y + S), "W": (x, y, x, y + S), "E": (x + S, y, x + S, y + S)}.items(): if (r + DR[d], c + DC[d]) not in cs: el.append(f'') # grooves paths = [] if gl: for cell, dirs in gl.items(): x, y = _xy(cell[0], cell[1], ox, oy) paths += _groove_cell(x, y, dirs) elif show_clue_grooves: for cell, dirs in g.clues.items(): x, y = _xy(cell[0], cell[1], ox, oy) paths += _groove_cell(x, y, set(dirs)) if paths: d = " ".join(paths) el.append(f'') el.append(f'') if ghost_path: pts = " ".join(f"{ox + c * S + S / 2:.1f},{oy + r * S + S / 2:.1f}" for r, c in ghost_path) el.append(f'') if footprints: for i, (r, c) in enumerate(footprints): x, y = _xy(r, c, ox, oy) cx, cy = x + S / 2, y + S / 2 if i + 1 < len(footprints): nr, nc = footprints[i + 1] else: pr, pc_ = footprints[i - 1] nr, nc = 2 * r - pr, 2 * c - pc_ ang = math.degrees(math.atan2(nr - r, nc - c)) for side in (-1, 1): el.append(f'') # ripple-stone marks: dotted circle through the ring if show_marks and not draw: for r in range(g.H): for c in range(g.W): if g.rows[r][c] == "O": x, y = _xy(r, c, ox, oy) cx, cy = x + S / 2, y + S / 2 for rad in (S * 0.95, S * 1.25): el.append(f'') # stones for comp in _components(g, set("#O")): rs = [p[0] for p in comp] cs_ = [p[1] for p in comp] cx = ox + (min(cs_) + max(cs_) + 1) / 2 * S cy = oy + (min(rs) + max(rs) + 1) / 2 * S rad = 0.5 * S * max(max(rs) - min(rs) + 1, max(cs_) - min(cs_) + 1) * 0.92 if len(comp) > 1 and len(comp) != (max(rs) - min(rs) + 1) * (max(cs_) - min(cs_) + 1): # irregular shape: draw each cell as a rounded blob for (r, c) in comp: x, y = _xy(r, c, ox, oy) el.append(f'') continue el.append(f'') el.append(f'') el.append(f'') # the abbot's pebbles (shape marks): pale = straight through, dark = turn; small stones off-centre in the cell if show_marks and not draw: for (r, c), t in sorted(getattr(g, "marks", {}).items()): x, y = _xy(r, c, ox, oy) prng = random.Random(r * 1009 + c * 17 + 5) px, py, pr = x + S / 2 + 0.17 * S, y + S / 2 - 0.17 * S, 0.14 * S el.append(f'') if t == "S": el.append(f'') else: el.append(f'') # gate: gap in the wall + shoe-stone outside gr, gc, gd = g.gate x, y = _xy(gr, gc, ox, oy) if gd == "S": gx, gy, gw, gh = x + 4, y + S, S - 8, WALL sx, sy = x + S / 2, y + S + WALL + 0 elif gd == "N": gx, gy, gw, gh = x + 4, y - WALL, S - 8, WALL sx, sy = x + S / 2, y - WALL elif gd == "W": gx, gy, gw, gh = x - WALL, y + 4, WALL, S - 8 sx, sy = x - WALL, y + S / 2 else: gx, gy, gw, gh = x + S, y + 4, WALL, S - 8 sx, sy = x + S + WALL, y + S / 2 if show_gate: el.append(f'') if show_gate and show_gate_arrow: ax, ay = gx + gw / 2, gy + gh / 2 rot = {"S": 0, "N": 180, "W": 90, "E": -90}[gd] el.append(f'') # two-gate gardens: the side gate through which the stroke must begin side = getattr(g, "end_side", None) if g.end is not None and side: x, y = _xy(g.end[0], g.end[1], ox, oy) if side == "N": bx, by, bw, bh, rot = x + 4, y - WALL, S - 8, WALL, 0 elif side == "S": bx, by, bw, bh, rot = x + 4, y + S, S - 8, WALL, 180 elif side == "W": bx, by, bw, bh, rot = x - WALL, y + 4, WALL, S - 8, -90 else: bx, by, bw, bh, rot = x + S, y + 4, WALL, S - 8, 90 el.append(f'') el.append(f'') if draw and highlight_start: r, c = draw[-1] x, y = _xy(r, c, ox, oy) el.append(f'') if monk is not None: r, c = monk x, y = _xy(r, c, ox, oy) el.append(f'') el.append(f'') if marks_svg: el.append(marks_svg(ox, oy)) if label: el.append(f'{label}') if sublabel: el.append(f'{sublabel}') el.append("") return "\n".join(el) def hstack(svgs, gap=10): """Place several SVG panels side by side in one SVG.""" import re dims = [] bodies = [] for s in svgs: m = re.search(r'width="([\d.]+)" height="([\d.]+)"', s) w, h = float(m.group(1)), float(m.group(2)) dims.append((w, h)) body = s.split(">", 1)[1].rsplit("", 1)[0] bodies.append(body) W = sum(w for w, _ in dims) + gap * (len(svgs) - 1) H = max(h for _, h in dims) out = [f'', f''] x = 0 for (w, h), body in zip(dims, bodies): out.append(f'{body}') x += w + gap out.append("") return "\n".join(out) def save(svg, path_png, path_svg=None, scale=2.0): import cairosvg if path_svg: with open(path_svg, "w") as f: f.write(svg) cairosvg.svg2png(bytestring=svg.encode(), write_to=path_png, scale=scale) src/search2.py — Second-pass searches: prettier unique gardens for the wide rake and two-gate mechanics. """Second-pass searches: prettier unique gardens for the wide rake and two-gate mechanics.""" import json, random, sys, time from multiprocessing import Pool from gen import random_layout, parity_ok from zg import Garden, turns_of, hairpin_list from grader import grade from levels import to_dict def wide_job(seed): rng = random.Random(seed) out = [] t0 = time.time() while time.time() - t0 < 240: H, W = rng.choice([(7, 7), (7, 8), (8, 8)]) g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g): continue g.nohairpin = 2; g.braced_start = True r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue d = r["sols"][0]["draw"] dens = turns_of(g, d) / len(d) if dens > 0.45: continue gr = grade(g) if gr["solved"] and gr["tier"] >= 3: out.append((dens, gr["trials"], to_dict(g), d)) return out def two_job(seed): rng = random.Random(seed) out = [] t0 = time.time() while time.time() - t0 < 240: H, W = rng.choice([(7, 7), (6, 8), (7, 8)]) g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1) if g is None: continue side = rng.choice("EWN") if side == "E": cells = [(r, W - 1) for r in range(H - 2)] elif side == "W": cells = [(r, 0) for r in range(H - 2)] else: cells = [(0, c) for c in range(W)] cells = [c for c in cells if g.rows[c[0]][c[1]] == "."] if not cells: continue g.end = rng.choice(cells) r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue d = r["sols"][0]["draw"] dens = turns_of(g, d) / len(d) ub = sum(1 for *_, br in hairpin_list(g, d) if not br) if dens > 0.42 or ub > 0: continue gr = grade(g) if gr["solved"]: dd = to_dict(g); dd["end_side"] = side out.append((dens, gr["tier"], gr["trials"], dd, d)) return out if __name__ == "__main__": kind = sys.argv[1] with Pool(2) as p: res = p.map(wide_job if kind == "wide" else two_job, [11, 12]) res = [x for r in res for x in r] res.sort(key=lambda x: x[0]) print(kind, "found", len(res)) for x in res[:8]: print(x[:3] if kind == "wide" else x[:3], x[-2]["rows"] if kind == "wide" else x[3]["rows"]) json.dump(res[:20], open(f"/home/claude/zen/out/search2_{kind}.json", "w")) src/search3.py import json, random, sys, time from multiprocessing import Pool from gen import random_layout, parity_ok from zg import turns_of, hairpin_list from grader import grade from levels import to_dict def wide_job(seed): rng = random.Random(seed) out = {} t0 = time.time() while time.time() - t0 < float(sys.argv[2]): H, W = rng.choice([(7, 7), (7, 8), (8, 8)]) k = rng.choice([2, 3, 4]) g = random_layout(H, W, k, rng, gate_side="S", min_gap=1) if g is None or not parity_ok(g): continue g.nohairpin = 2; g.braced_start = True key = (tuple(g.rows), tuple(g.gate)) if key in out: continue r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue d = r["sols"][0]["draw"] dens = turns_of(g, d) / len(d) if dens > 0.40: continue gr = grade(g) if gr["solved"] and gr["tier"] >= 3: out[key] = (dens, gr["trials"], k, to_dict(g), d) return list(out.values()) if __name__ == "__main__": with Pool(2) as p: res = p.map(wide_job, [101, 202]) res = [x for r in res for x in r] res.sort(key=lambda x: (x[0], -x[2])) print("found", len(res)) for x in res[:12]: print(round(x[0],3), x[1], x[2], x[3]["rows"], x[3]["gate"]) json.dump(res, open("/home/claude/zen/out/search3_wide.json", "w")) src/search4.py import json, random, sys, time from multiprocessing import Pool from gen import random_layout, parity_ok from zg import turns_of, hairpin_list from grader import grade from levels import to_dict def job(seed): rng = random.Random(seed) out = {} t0 = time.time() while time.time() - t0 < float(sys.argv[1]): H, W = rng.choice([(7, 7), (7, 8), (8, 8)]) k = rng.choice([1, 2, 3]) g = random_layout(H, W, k, rng, gate_side="S", min_gap=1) if g is None: continue side = rng.choice("EWN") if side == "E": cells = [(r, W - 1) for r in range(H - 2)] elif side == "W": cells = [(r, 0) for r in range(H - 2)] else: cells = [(0, c) for c in range(W)] cells = [c for c in cells if g.rows[c[0]][c[1]] == "."] if not cells: continue g.end = rng.choice(cells) wide = rng.random() < 0.5 if wide: g.nohairpin = 2; g.braced_start = True key = (tuple(g.rows), tuple(g.gate), g.end, wide) if key in out: continue r = g.solve(mode=0, cap=2) if r["nsol"] != 1: continue d = r["sols"][0]["draw"] dens = turns_of(g, d) / len(d) ub = sum(1 for *_, br in hairpin_list(g, d) if not br) if dens > 0.40 or ub > 0: continue gr = grade(g) if gr["solved"]: dd = to_dict(g); dd["end_side"] = side out[key] = (dens, gr["tier"], gr["trials"], wide, dd, d) return list(out.values()) if __name__ == "__main__": with Pool(2) as p: res = p.map(job, [7, 8]) res = [x for r in res for x in r] res.sort(key=lambda x: x[0]) print("found", len(res), "wide:", sum(x[3] for x in res)) for x in res[:12]: print(round(x[0],3), x[1], x[2], x[3], x[4]["rows"], x[4]["gate"], x[4]["end"], x[4]["end_side"]) json.dump(res, open("/home/claude/zen/out/search4_two.json", "w")) src/search_finale.py import json, random, sys, time from multiprocessing import Pool from zg import Garden, turns_of, hairpin_list from gen import parity_ok from levels import to_dict def polyomino(k, rng): cells = {(0, 0)} while len(cells) < k: r, c = rng.choice(sorted(cells)) d = rng.choice([(0,1),(1,0),(0,-1),(-1,0)]) cells.add((r + d[0], c + d[1])) mr = min(r for r, c in cells); mc = min(c for r, c in cells) return {(r - mr, c - mc) for r, c in cells} def layout(H, W, sizes, rng): rows = [["."] * W for _ in range(H)] occupied = set() for k in sizes: for _ in range(200): p = polyomino(k, rng) ph = max(r for r, c in p) + 1; pw = max(c for r, c in p) + 1 if ph > min(3, H - 2) or pw > W - 2: continue r0 = rng.randint(1, H - ph - 1); c0 = rng.randint(1, W - pw - 1) cells = {(r0 + r, c0 + c) for r, c in p} halo = {(r + dr, c + dc) for r, c in cells for dr in (-2, -1, 0, 1, 2) for dc in (-2, -1, 0, 1, 2)} if halo & occupied: continue for r, c in cells: rows[r][c] = "#" occupied |= cells break else: return None return rows def job(seed): rng = random.Random(seed); out = []; t0 = time.time(); tried = 0 H, W = int(sys.argv[2]), int(sys.argv[3]) while time.time() - t0 < float(sys.argv[1]): sizes = [5, 3, 3, 2, 2]; rng.shuffle(sizes) rows = layout(H, W, sizes, rng) if rows is None: continue gc = rng.choice([c for c in range(W) if rows[H-1][c] == "."]) g = Garden(["".join(r) for r in rows], (H - 1, gc, "S"), nohairpin=2, braced_start=True) tried += 1 if not parity_ok(g): continue r = g.solve(mode=0, cap=2, nodelimit=5 * 10**7) if r["nsol"] != 1 or not r["complete"]: continue d = r["sols"][0]["draw"] dens = turns_of(g, d) / len(d) out.append((dens, to_dict(g), d, sizes)) return out, tried if __name__ == "__main__": with Pool(2) as p: res = p.map(job, [int(sys.argv[4]), int(sys.argv[4]) + 1]) rows = [x for r, _ in res for x in r]; tried = sum(t for _, t in res) rows.sort(key=lambda x: x[0]) print("tried", tried, "unique", len(rows)) for x in rows[:6]: print(round(x[0], 3), x[3], x[1]["rows"], x[1]["gate"]) json.dump(rows[:30], open(f"/home/claude/zen/out/finale_{sys.argv[2]}x{sys.argv[3]}.json", "w")) src/search_finale2.py import json, random, sys, time from zg import Garden, turns_of, hairpin_list from gen import parity_ok from levels import to_dict from zcp import optimize from search_finale import layout rng = random.Random(int(sys.argv[3])) H, W = int(sys.argv[1]), int(sys.argv[2]) t0 = time.time(); out = []; tried = 0 while time.time() - t0 < float(sys.argv[4]): sizes = [5, 3, 3, 2, 2]; rng.shuffle(sizes) rows = layout(H, W, sizes, rng) if rows is None: continue gc = rng.choice([c for c in range(W) if rows[H-1][c] == "."]) g = Garden(["".join(r) for r in rows], (H - 1, gc, "S")) tried += 1 if not parity_ok(g): continue if g.solve(mode=0, cap=1, nodelimit=10**7)['nsol'] == 0: continue free = optimize(g, time_limit=60, workers=2) if free[1] != 'OPTIMAL': continue grace = optimize(g, nohairpin=2, time_limit=60, workers=2) ok = grace[1] == 'OPTIMAL' and grace[0] == free[0] print(f"tried {tried} par {free[0]} graceful {grace[0]} {grace[1]} ok={ok} ({time.time()-t0:.0f}s)", flush=True) if ok: out.append((free[0], to_dict(g), list(reversed(grace[2])), sizes)) json.dump(out, open(f"/home/claude/zen/out/finale2_{H}x{W}_{sys.argv[3]}.json", "w")) print("found", len(out)) src/seed_garden_names.py — One-time seeding of out/garden_names.json: the names the gardens had in the 136-garden campaign (the one Astra chose """One-time seeding of out/garden_names.json: the names the gardens had in the 136-garden campaign (the one Astra chose poems for), recovered by matching each current garden's layout to that campaign's per-level record (size, gate, pars and counts, stones, marks, and side gate; side-gate pars are matched without par, since the bend inside a side gate now counts as a turn). From now on campaign.py names a garden by its layout, so a garden keeps its name (and its poem, and a player's saved progress) when the campaign is rearranged. The two world-7 ring gardens generated since then take the names of the two ring gardens they replaced.""" import json, collections A = json.load(open('/home/claude/zen/poems/astra-samon-poem-selection.json')) C = json.load(open('/home/claude/zen/out/campaign.json')) def full_key(d): return json.dumps([d["rows"], d["gate"], d["end"], d.get("end_side"), d["nohairpin"], bool(d["braced_start"]), sorted(d["clues"].items()), sorted((d.get("marks") or {}).items())]) def sig_new(e, par=True): d = e["garden"]; rows = d["rows"]; s = "".join(rows); marks = d.get("marks") or {} return (len(rows), len(rows[0]), tuple(d["gate"]), e.get("par") if par else None, s.count('#'), s.count('O'), s.count('R'), s.count('h'), s.count('v'), s.count('='), s.count('|'), len(d["clues"]), sum(v == 'S' for v in marks.values()), sum(v == 'B' for v in marks.values()), d["nohairpin"] == 2, tuple(d["end"]) if d["end"] else None) def sig_old(L, par=True): m = L["mechanics"] return (m["rows"], m["columns"], tuple(m["gate_zero_based"]), m["par_turns"] if par else None, m["ordinary_stone_cells"], m["ripple_stones"], m["protected_cells"], m["horizontal_band_cells"], m["vertical_band_cells"], m["horizontal_block_cells"], m["vertical_block_cells"], m["given_grooves"], m["pale_pebbles"], m["dark_pebbles"], m["wide_rake"], tuple(m["second_endpoint_zero_based"]) if m["second_endpoint_zero_based"] else None) P = {p["name"]: p for p in json.load(open('/home/claude/zen/out/proto_gardens.json'))} for e in C: e["par"] = P[e["name"]]["par"] old = collections.defaultdict(list) for L in A["levels"]: old[sig_old(L, False)].append(L) reg, used = {}, set() for e in C: cands = [L for L in old.get(sig_new(e, False), []) if L["name"] not in used] if not cands: continue exact = [L for L in cands if L["mechanics"]["par_turns"] == e["par"]] side = e["garden"]["end"] is not None cands = exact if exact else ([L for L in cands if L["mechanics"]["par_turns"] + 1 == e["par"]] if side else []) if len(cands) > 1: # two 5x5 wide-rake gardens with a two-cell stone on the right wall: told apart by Astra's description rows = e["garden"]["rows"] above = rows[1][-1] == "#" cands = [L for L in cands if ("above the midpoint" in L["description"]) == above] assert len(cands) == 1, (e["name"], [L["name"] for L in cands]) reg[full_key(e["garden"])] = cands[0]["name"]; used.add(cands[0]["name"]) # the two world-7 ring gardens that replaced "Lantern festival" and "Dark water" (the ring spec was regenerated) rings7 = [e for e in C if e["world"] == 7 and "R" in "".join(e["garden"]["rows"]) and full_key(e["garden"]) not in reg] assert len(rings7) == 2 and {"Lantern festival", "Dark water"}.isdisjoint(used), [e["name"] for e in rings7] rings7.sort(key=lambda e: (e["role"] != "intro", e["name"])) for e, nm in zip(rings7, ("Lantern festival", "Dark water")): reg[full_key(e["garden"])] = nm; used.add(nm) missing = [L["name"] for L in A["levels"] if L["name"] not in used] print(len(reg), "layouts named;", "old names with no layout now:", missing) json.dump([{"name": v, "key": k} for k, v in sorted(reg.items(), key=lambda kv: kv[1])], open('/home/claude/zen/out/garden_names.json', 'w'), indent=0) src/sound_pieces.py — The recordings carried in the page, as HTML for the builders (proto/build.py, standalone/build_standalone.py). """The recordings carried in the page, as HTML for the builders (proto/build.py, standalone/build_standalone.py). out/sound_pieces.json (sound/make_sound_bundle.py) holds each encoded recording with its credits. Each becomes an inert ') return "\n".join(out) src/test_crosscheck.py — Cross-check the C solver against the brute-force Python enumerator on random small gardens. """Cross-check the C solver against the brute-force Python enumerator on random small gardens.""" import random import sys from zg import Garden, brute_force, check_solution random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 1) def random_garden(): H = random.randint(3, 5) W = random.randint(3, 5) while H * W > 22: W -= 1 rows = [["." for _ in range(W)] for _ in range(H)] for _ in range(random.randint(0, 3)): r, c = random.randrange(H), random.randrange(W) rows[r][c] = "#" # maybe a ripple stone if random.random() < 0.3 and H >= 3 and W >= 3: r, c = random.randint(1, H - 2), random.randint(1, W - 2) for rr in range(r - 1, r + 2): for cc in range(c - 1, c + 2): rows[rr][cc] = "." rows[r][c] = "O" # maybe a wave band if random.random() < 0.3: sym = random.choice("hv") r0, c0 = random.randrange(H), random.randrange(W) for rr in range(r0, min(H, r0 + random.randint(1, 2))): for cc in range(c0, min(W, c0 + random.randint(1, 3))): if rows[rr][cc] == ".": rows[rr][cc] = sym # gate on the border border = [(r, c) for r in range(H) for c in range(W) if (r in (0, H - 1) or c in (0, W - 1)) and rows[r][c] in ".hv"] if not border: return None gr, gc = random.choice(border) sides = [s for s, ok in (("N", gr == 0), ("S", gr == H - 1), ("W", gc == 0), ("E", gc == W - 1)) if ok] clues = {} if random.random() < 0.3: sand = [(r, c) for r in range(H) for c in range(W) if rows[r][c] == "."] if sand: cell = random.choice(sand) clues[cell] = random.choice(["EW", "NS", "NE", "ES", "SW", "NW"]) g = Garden(["".join(r) for r in rows], (gr, gc, random.choice(sides)), clues=clues, nohairpin=random.choice([0, 0, 1, 2]), braced_turns=random.random() < 0.25, braced_start=random.random() < 0.3) if random.random() < 0.15: sand = g.sand_cells() g.end = random.choice(sand) return g ok = bad = 0 for trial in range(int(sys.argv[2]) if len(sys.argv) > 2 else 150): g = random_garden() if g is None: continue try: g.ring_sets() except ValueError: continue res = g.solve(mode=0, cap=10**9) n_bf, hist_bf = brute_force(g) sols_ok = all(check_solution(g, s["draw"]) for s in res["sols"]) if res["nsol"] != n_bf or res["hist"] != hist_bf or not sols_ok: bad += 1 print("MISMATCH", g, res["nsol"], n_bf, res["hist"], hist_bf, sols_ok) else: ok += 1 print("ok", ok, "bad", bad) src/test_grader.py import random, time from gen import random_layout from clues import make_unique, with_clues from grader import grade, Board, State from zg import glyphs rng = random.Random(3) tiers = {} t0 = time.time() done = 0 while done < 40: variant = rng.choice(["plain", "wide"]) g = random_layout(6, 6, rng.choice([0, 1, 2, 3]), rng, gate_side="S") if g is None: continue if variant == "wide": g.nohairpin = 2; g.braced_start = True r = g.solve(mode=0, cap=1) if r["nsol"] == 0: continue clues = make_unique(g, r["sols"][0]["draw"], rng) if clues is None: continue gu = with_clues(g, clues) ru = gu.solve(mode=0, cap=2) assert ru["nsol"] == 1 res = grade(gu) tiers.setdefault((variant, res["tier"]), 0) tiers[(variant, res["tier"])] += 1 done += 1 print(variant, len(clues), res, flush=True) print(sorted(tiers.items()), f"{time.time()-t0:.0f}s") src/test_grader_marks.py — Soundness of the grader's deductions (incl. shape marks): on random gardens, it must never derive a """Soundness of the grader's deductions (incl. shape marks): on random gardens, it must never derive a contradiction when a raking exists, and when it claims a solution, that must be the unique raking.""" import random, sys from zg import Garden, glyphs from grader import grade, Board, State, trial_loop, Contradiction rng = random.Random(int(sys.argv[1]) if len(sys.argv) > 1 else 7) n_ok = n_bad = n_uniq = n_solved = 0 for t in range(int(sys.argv[2]) if len(sys.argv) > 2 else 300): H, W = rng.randint(4, 6), rng.randint(4, 6) rows = [["."] * W for _ in range(H)] for _ in range(rng.randint(0, 3)): rows[rng.randrange(H)][rng.randrange(W)] = "#" if rows[H - 1][0] != ".": continue g = Garden(["".join(r) for r in rows], (H - 1, 0, "S")) sand = g.sand_cells() if rng.random() < 0.3: g.nohairpin = 2; g.braced_start = True if rng.random() < 0.5: # random marks (often unsolvable) for _ in range(rng.randint(1, 6)): g.marks[rng.choice(sand)] = rng.choice("SB") else: # marks taken from one raking (solvable, often unique) r0 = g.solve(mode=0, cap=200, stream=True) if not r0["sols"]: continue draw = rng.choice(r0["sols"])["draw"] gl = glyphs(g, draw) two = [c for c in draw[:-1] if len(gl[c]) == 2] for c in rng.sample(two, min(len(two), rng.randint(2, 10))): g.marks[c] = "S" if set(gl[c]) in ({"E", "W"}, {"N", "S"}) else "B" r = g.solve(mode=0, cap=3, stream=True) res = grade(g) bad = False if r["nsol"] >= 1 and res["tier"] == 0: bad = True # contradiction although a raking exists if r["nsol"] == 0 and res["solved"]: bad = True if r["nsol"] >= 2 and res["solved"]: bad = True if r["nsol"] == 1: n_uniq += 1 if res["solved"]: n_solved += 1 # re-derive the grader's final edge set and compare with the solver's raking B = Board(g); st = State(B, use_global=True); st.propagate() for depth in range(2): if st.solved(): break st = trial_loop(st.copy(), depth, stats=None) draw = r["sols"][0]["draw"] want = {B.eid[(B.idx[a], B.idx[b])] for a, b in zip(draw, draw[1:])} got = {e for e, x in enumerate(st.s) if x == 1} bad |= want != got if bad: n_bad += 1; print("BAD", g, r["nsol"], res) else: n_ok += 1 print(f"grader soundness with marks: ok {n_ok} bad {n_bad} (unique {n_uniq}, solved by grader {n_solved})") src/test_marks.py — Cross-check shape marks ('S' straight / 'B' turn): C solver vs brute force (small gardens, all rules), """Cross-check shape marks ('S' straight / 'B' turn): C solver vs brute force (small gardens, all rules), streamed solutions vs counts, and C vs CP-SAT (medium gardens: counts, histograms, par).""" import random import sys from zg import Garden, brute_force, check_solution from zcp import count_solutions, optimize seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1 trials = int(sys.argv[2]) if len(sys.argv) > 2 else 2000 random.seed(seed) def random_garden(Hr=(3, 5), Wr=(3, 5), maxcells=22, rules=True): H, W = random.randint(*Hr), random.randint(*Wr) while H * W > maxcells: W -= 1 rows = [["." for _ in range(W)] for _ in range(H)] for _ in range(random.randint(0, 3)): rows[random.randrange(H)][random.randrange(W)] = "#" if rules and random.random() < 0.25 and H >= 3 and W >= 3: r, c = random.randint(1, H - 2), random.randint(1, W - 2) for rr in range(r - 1, r + 2): for cc in range(c - 1, c + 2): rows[rr][cc] = "." rows[r][c] = "O" if rules and random.random() < 0.25: sym = random.choice("hv") r0, c0 = random.randrange(H), random.randrange(W) for rr in range(r0, min(H, r0 + random.randint(1, 2))): for cc in range(c0, min(W, c0 + random.randint(1, 3))): if rows[rr][cc] == ".": rows[rr][cc] = sym border = [(r, c) for r in range(H) for c in range(W) if (r in (0, H - 1) or c in (0, W - 1)) and rows[r][c] in ".hv"] if not border: return None gr, gc = random.choice(border) sides = [s for s, ok in (("N", gr == 0), ("S", gr == H - 1), ("W", gc == 0), ("E", gc == W - 1)) if ok] g = Garden(["".join(r) for r in rows], (gr, gc, random.choice(sides))) sand = g.sand_cells() if rules: if random.random() < 0.2: g.clues[random.choice(sand)] = random.choice(["EW", "NS", "NE", "ES", "SW", "NW"]) g.nohairpin = random.choice([0, 0, 1, 2]) g.braced_start = random.random() < 0.3 if random.random() < 0.15: g.end = random.choice(sand) for _ in range(random.choice([1, 1, 2, 3])): cell = random.choice(sand) if cell not in g.clues: g.marks[cell] = random.choice("SB") try: g.ring_sets() except ValueError: return None return g ok = bad = solvable = 0 for t in range(trials): g = random_garden() if g is None: continue res = g.solve(mode=0, cap=10**9, stream=True) n_bf, hist_bf = brute_force(g) draws = [tuple(map(tuple, s["draw"])) for s in res["sols"]] good = (res["nsol"] == n_bf and res["hist"] == hist_bf and len(draws) == res["nsol"] and len(set(draws)) == len(draws) and all(check_solution(g, list(d)) for d in draws)) if good: ok += 1 solvable += res["nsol"] > 0 else: bad += 1 print("MISMATCH", g, res["nsol"], n_bf, res["hist"], hist_bf) print(f"small (C vs brute force, streamed sols checked): ok {ok} bad {bad} solvable {solvable}") # medium gardens: C vs CP-SAT on counts/histograms (when small enough) and on par ok2 = bad2 = 0 random.seed(seed + 1000) for t in range(max(20, trials // 50)): g = random_garden(Hr=(6, 7), Wr=(6, 7), maxcells=49, rules=False) if g is None: continue for _ in range(random.randint(1, 4)): g.marks[random.choice(g.sand_cells())] = random.choice("SB") res = g.solve(mode=0, cap=3000) if not res["complete"] or res["capped"]: continue if res["nsol"] == 0: st = optimize(g, time_limit=60)[1] good = st == "INFEASIBLE" else: n_cp, hist_cp, st = count_solutions(g, limit=10000) par = optimize(g, time_limit=60) good = n_cp == res["nsol"] and hist_cp == res["hist"] and par[0] == min(res["hist"]) and par[1] == "OPTIMAL" if good: ok2 += 1 else: bad2 += 1 print("CP-SAT MISMATCH", g, res["nsol"], res["hist"]) print(f"medium (C vs CP-SAT): ok {ok2} bad {bad2}") src/test_side_pivot.py — Cross-check the two rule changes of 2026-10-04 across all three solvers: """Cross-check the two rule changes of 2026-10-04 across all three solvers: (1) with a side gate, the bend where the stroke enters through it counts as a turn; (2) the abbot's finished sand ('R') is not walkable and is no pivot for a wide-rake U-turn or the rake's start. Small gardens: C solver vs brute force (counts, turn histograms, every solution valid). Medium gardens: C solver vs CP-SAT (minimum turns; counts on a subset).""" import random import sys from zg import Garden, brute_force, check_solution from zcp import optimize, count_solutions rng = random.Random(int(sys.argv[1]) if len(sys.argv) > 1 else 1) N_SMALL = int(sys.argv[2]) if len(sys.argv) > 2 else 1500 N_MEDIUM = int(sys.argv[3]) if len(sys.argv) > 3 else 60 def garden(Hr, Wr): H, W = rng.randint(*Hr), rng.randint(*Wr) rows = [["." for _ in range(W)] for _ in range(H)] for _ in range(rng.randint(0, 3)): rows[rng.randrange(H)][rng.randrange(W)] = rng.choice("##R") if rng.random() < 0.3: # a stone with some finished sand beside it r, c = rng.randrange(H), rng.randrange(W) rows[r][c] = "#" for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)): if 0 <= r + dr < H and 0 <= c + dc < W and rng.random() < 0.5: rows[r + dr][c + dc] = "R" if rng.random() < 0.2: sym = rng.choice("hv"); r0, c0 = rng.randrange(H), rng.randrange(W) for rr in range(r0, min(H, r0 + 2)): for cc in range(c0, min(W, c0 + 2)): if rows[rr][cc] == ".": rows[rr][cc] = sym border = [(r, c) for r in range(H) for c in range(W) if (r in (0, H - 1) or c in (0, W - 1)) and rows[r][c] in ".hv"] if len(border) < 2: return None gr, gc = rng.choice(border) sides = [s for s, ok in (("N", gr == 0), ("S", gr == H - 1), ("W", gc == 0), ("E", gc == W - 1)) if ok] g = Garden(["".join(r) for r in rows], (gr, gc, rng.choice(sides)), nohairpin=rng.choice([0, 1, 2, 2]), braced_start=rng.random() < 0.4) if rng.random() < 0.6: # a side gate in another border cell er, ec = rng.choice([b for b in border if b != (gr, gc)]) g.end = (er, ec) g.end_side = rng.choice([s for s, ok in (("N", er == 0), ("S", er == H - 1), ("W", ec == 0), ("E", ec == W - 1)) if ok]) if rng.random() < 0.2: sand = [c for c in g.sand_cells() if c != (gr, gc) and c != g.end] if sand: c = rng.choice(sand); g.marks = {c: rng.choice("SB")} return g ok = bad = solvable = side = soft = 0 for _ in range(N_SMALL): g = garden((3, 5), (3, 5)) if g is None or len(g.sand_cells()) > 16: continue res = g.solve(mode=0, cap=10**9) n_bf, hist_bf = brute_force(g) good = res["nsol"] == n_bf and res["hist"] == hist_bf and all(check_solution(g, s["draw"]) for s in res["sols"]) ok += good; bad += not good solvable += n_bf > 0; side += g.end_side is not None and n_bf > 0; soft += any("R" in r for r in g.rows) and n_bf > 0 if not good: print("MISMATCH small", g, res["nsol"], n_bf, res["hist"], hist_bf) print(f"small (C vs brute force): ok {ok} bad {bad} solvable {solvable} (side gate {side}, finished sand {soft})") okm = badm = countchecks = 0 for i in range(N_MEDIUM): g = None while g is None or not g.sand_cells(): g = garden((5, 6), (6, 7)) best = g.solve(mode=1, cap=10**9, nodelimit=2 * 10**8) opt = optimize(g, time_limit=60) c_min = best["best"]["turns"] if "best" in best else None good = (c_min is None and opt[1] == "INFEASIBLE") or (c_min == opt[0] and opt[1] == "OPTIMAL") if good: # full counts and histograms where enumeration is cheap c = g.solve(mode=0, cap=10**9) if c["nsol"] <= 20000: n, h, st = count_solutions(g, workers=1) good = c["nsol"] == n and c["hist"] == h countchecks += 1 okm += good; badm += not good if not good: print("MISMATCH medium", g, c_min, opt[:2]) print(f"medium (C vs CP-SAT): ok {okm} bad {badm} (counts and histograms compared on {countchecks})") src/verify_doc.py — Independent re-derivation of every number stated in samon.md (C solver for counts; CP-SAT for pars/uniqueness). """Independent re-derivation of every number stated in samon.md (C solver for counts; CP-SAT for pars/uniqueness).""" import json, collections, re from zg import Garden, turns_of, hairpin_list, check_solution from zcp import optimize, count_solutions from levels import from_dict L = json.load(open('/home/claude/zen/out/levels.json')) fails = [] def check(name, got, want): ok = got == want print(f"{'OK ' if ok else 'BAD'} {name}: got {got} want {want}") if not ok: fails.append(name) def med(h): tot = sum(h.values()); acc = 0 for t in sorted(h): acc += h[t] if acc >= tot / 2: return t # Table 1 T1 = {(3,3): (8,4,2,5,6,2), (4,4): (52,6,4,8,13,4), (5,5): (824,8,10,13,20,4), (6,6): (22144,10,24,20,31,6), (7,7): (1510446,12,66,27,42,6), (6,8): (899432,11,66,26,42,8)} for (H, W), want in T1.items(): g = Garden(["."*W]*H, (H-1, 0, "S")); r = g.solve(mode=0, cap=10**12); h = r['hist'] gw = Garden(["."*W]*H, (H-1, 0, "S"), nohairpin=2, braced_start=True); rw = gw.solve(mode=0, cap=10**9) got = (r['nsol'], min(h), h[min(h)], med(h), max(h), rw['nsol']) check(f"table1 {H}x{W}", got, want) if H*W <= 36: check(f"table1 {H}x{W} CP-SAT par", optimize(g)[0], want[1]) # A145157 9x9 is cited from OEIS (not recomputed: 5.5e10 paths) # parity check("parity ok", Garden(["....."]*5, (4,0,"S")).solve(mode=0, cap=10**9)['nsol'], 824) check("parity bad", Garden(["....."]*5, (4,1,"S")).solve(mode=0, cap=10**9)['nsol'], 0) # anatomy / first garden g = from_dict(L['first']['garden']); r = g.solve(mode=0, cap=10**9) check("first n", r['nsol'], 1023); check("first par", min(r['hist']), 10); check("first at par", r['hist'][10], 2) check("first CP-SAT par", optimize(g)[0], 10) # calm garden g = from_dict(L['calm']['garden']); r = g.solve(mode=0, cap=10**9); h = r['hist'] check("calm n", r['nsol'], 103812); check("calm par", min(h), 17); check("calm at par", h[17], 6) check("calm median", med(h), 31); check("calm max", max(h), 48); check("calm at max", h[48], 5) check("calm CP-SAT par", optimize(g)[0], 17) # ripple g = from_dict(L['ripple']['garden']); r = g.solve(mode=0, cap=10**9); h = r['hist'] check("ripple n", r['nsol'], 35570); check("ripple par", min(h), 12); check("ripple at par", h[12], 4) g0 = Garden([x.replace('O', '#') for x in g.rows], g.gate); r0 = g0.solve(mode=0, cap=10**9) check("ripple plain n", r0['nsol'], 183986); check("ripple plain par", min(r0['hist']), 12); check("ripple plain at par", r0['hist'][12], 5) d = [tuple(c) for c in L['ripple']['intended']] check("ripple intended valid", check_solution(g, d), True) check("ripple start adjacent to stone", any(abs(d[-1][0]-4)<=1 and abs(d[-1][1]-2)<=1 for _ in [0]), True) # waves g = from_dict(L['waves']['garden']); r = g.solve(mode=0, cap=10**9); h = r['hist'] check("waves n", r['nsol'], 2984); check("waves par", min(h), 17); check("waves at par", h[17], 34) g0 = Garden([x.replace('h', '.') for x in g.rows], g.gate); r0 = g0.solve(mode=0, cap=10**9) check("waves plain n", r0['nsol'], 1492008); check("waves plain par", min(r0['hist']), 16) # ichimatsu gi = Garden(["".join("=" if ((rr//3)+(cc//3))%2==0 else "|" for cc in range(9)) for rr in range(9)], (8,0,"S")) ri = gi.solve(mode=0, cap=100) check("ichimatsu 9x9 n", ri['nsol'], 2); check("ichimatsu turns", sorted(s['turns'] for s in ri['sols']), [45, 45]) n_cp, _, st = count_solutions(gi, limit=10) check("ichimatsu CP-SAT count", n_cp, 2) g6 = ["===|||"]*3 + ["|||==="]*3 uniq = [gc for gc in range(6) if Garden(g6, (5, gc, "S")).solve(mode=0, cap=5)['nsol'] == 1] check("ichimatsu 6x6 unique gate cols", uniq, [0, 1, 2, 3, 5]) # grooves g = from_dict(L['grooves']['garden']) check("grooves count", len(g.clues), 5) check("grooves unique", g.solve(mode=0, cap=5)['nsol'], 1) check("grooves CP-SAT unique", count_solutions(g, limit=5)[0], 1) g0 = Garden(g.rows, g.gate); r0 = g0.solve(mode=0, cap=10**9) check("grooves plain n", r0['nsol'], 12375); check("grooves plain par", min(r0['hist']), 21); check("grooves plain at par", r0['hist'][21], 11) d = [tuple(c) for c in L['grooves']['intended']] check("grooves answer turns", turns_of(g, d), 21) # wide rake w = json.load(open('/home/claude/zen/out/wide_pick.json')) g = from_dict(w['garden']) check("wide unique (C)", g.solve(mode=0, cap=5)['nsol'], 1) check("wide unique (CP-SAT)", count_solutions(g, limit=5)[0], 1) d = [tuple(c) for c in w['draw']] check("wide answer turns", turns_of(g, d), 17) gp = Garden(g.rows, g.gate); rp = gp.solve(mode=0, cap=10**9) check("wide plain n", rp['nsol'], 6310226); check("wide plain par", min(rp['hist']), 17); check("wide plain at par", rp['hist'][17], 12) pr = gp.solve(mode=2, bound=17, cap=100) graceful = [s for s in pr['sols'] if not any(not br for *_, br in hairpin_list(gp, s['draw']))] check("wide: graceful par rakings", len(graceful), 1) check("wide: graceful par == wide answer", graceful[0]['draw'] == d, True) # two gates t = json.load(open('/home/claude/zen/out/two_pick.json')) g = from_dict(t['garden']) check("two-gate wide unique", g.solve(mode=0, cap=5)['nsol'], 1) check("two-gate CP-SAT unique", count_solutions(g, limit=5)[0], 1) d = [tuple(c) for c in t['draw']]; check("two-gate answer turns (the bend entering through the side gate counts)", turns_of(g, d), 20) gp = Garden(g.rows, g.gate, end=g.end, end_side=g.end_side); rp = gp.solve(mode=0, cap=10**9) check("two-gate plain n", rp['nsol'], 311080); check("two-gate plain par", min(rp['hist']), 20); check("two-gate plain at par", rp['hist'][20], 32) check("two-gate plain par by CP-SAT", optimize(gp, time_limit=120)[:2], (20, 'OPTIMAL')) # the abbot's pebbles: the example garden from grader import grade mp = json.load(open('/home/claude/zen/out/marks_pick.json')) gm = from_dict(mp['garden']); dm = [tuple(c) for c in mp['draw']] gro = {tuple(map(int, k.split(','))): v for k, v in mp['grooves'].items()} gg = Garden(gm.rows, gm.gate, clues=gro); gp = Garden(gm.rows, gm.gate) rp = gp.solve(mode=0, cap=10**8) check("pebbles garden plain n", rp['nsol'], 59498); check("pebbles garden par", min(rp['hist']), 13); check("pebbles garden at par", rp['hist'][13], 6) check("pebbles count", len(gm.marks), 8); check("pebbles unique (C)", gm.solve(mode=0, cap=5)['nsol'], 1) check("pebbles unique (CP-SAT)", count_solutions(gm, limit=5)[0], 1) check("pebbles answer turns", turns_of(gm, dm), 13); check("pebbles answer valid", check_solution(gm, dm), True) check("grooves version count", len(gro), 6); check("grooves version unique", gg.solve(mode=0, cap=5)['nsol'], 1) gr1, gr2 = grade(gm), grade(gg) check("pebbles tier/trials", (gr1['tier'], gr1['trials']), (3, 14)); check("grooves version tier", gr2['tier'], 1) # pebbles vs grooves experiment: re-check every saved garden's uniqueness and grade, then the statistics import statistics rows = json.load(open('/home/claude/zen/out/marks_vs_grooves.json')) check("experiment gardens", len(rows), 88) uniq_ok, gts, mts = True, [], [] for r in rows: base = dict(rows=r['rows'], gate=r['gate'], clues={}, end=None, nohairpin=0, braced_start=False, marks={}) g1 = from_dict({**base, 'clues': r['grooves']}); g2 = from_dict({**base, 'marks': r['marks']}) tgt = [tuple(c) for c in r['target']] for gx in (g1, g2): rr = gx.solve(mode=0, cap=3, stream=True) uniq_ok &= rr['nsol'] == 1 and [tuple(c) for c in rr['sols'][0]['draw']] == tgt a, b = grade(g1), grade(g2) gts.append((a['tier'], a['trials'])); mts.append((b['tier'], b['trials'])) check("experiment: every garden unique both ways, with the intended answer", uniq_ok, True) check("experiment: recomputed grades match saved", [(r['g_tier'], r['g_trials']) for r in rows] == gts and [(r['m_tier'], r['m_trials']) for r in rows] == mts, True) ng, nm = [len(r['grooves']) for r in rows], [len(r['marks']) for r in rows] check("median grooves / pebbles", (statistics.median(ng), statistics.median(nm)), (7, 11)) check("share of gardens needing more pebbles than grooves (%)", round(100 * sum(b > a for a, b in zip(ng, nm)) / 88), 92) check("lookahead share grooves / pebbles (%)", (round(100 * sum(t >= 3 for t, _ in gts) / 88), round(100 * sum(t >= 3 for t, _ in mts) / 88)), (47, 92)) check("nested lookahead grooves / pebbles (%)", (sum(t >= 4 for t, _ in gts), round(100 * sum(t >= 4 for t, _ in mts) / 88, 1)), (0, 12.5)) check("median lookahead steps grooves / pebbles", (statistics.median([n for t, n in gts if t >= 3]), statistics.median([n for t, n in mts if t >= 3])), (2, 7)) # prototype: the full campaign (each garden's par, raking count, par rakings and hint are re-derived, with asserts, by export_proto.py) P = json.load(open('/home/claude/zen/out/proto_gardens.json')) check("prototype gardens", len(P), 148) check("gardens per world", [sum(p['world'] == w for p in P) for w in range(1, 8)], [19, 20, 20, 25, 26, 18, 20]) check("main path / extras per world", ([sum(p['world'] == w and not p['extra'] for p in P) for w in range(1, 8)], [sum(p['world'] == w and p['extra'] for p in P) for w in range(1, 8)]), ([9, 10, 11, 16, 9, 9, 8], [10, 10, 9, 9, 17, 9, 12])) check("main path / extras totals", (sum(not p['extra'] for p in P), sum(p['extra'] for p in P)), (72, 76)) check("the first five gardens", [(p['name'], p['role']) for p in P[:5]], [('The corridor', 'intro'), ('Nine squares', 'intro'), ('Morning dew', 'application'), ('The pocket', 'intro'), ('Two tones', 'intro')]) check("the third garden is the first generated one, 5x5 with one stone", (sum(ch == '#' for r in P[2]['rows'] for ch in r) > 0, len(P[2]['rows']), len(P[2]['rows'][0])), (True, 5, 5)) check("the 2x3 garden is gone", any(p['name'] == 'Two rows' for p in P), False) import math need = [math.ceil(2 / 3 * sum(p['world'] == w and not p['extra'] for p in P)) for w in range(1, 7)] check("gardens raked before grooves (world 4) / the wide rake (world 5) open", (sum(need[:3]), sum(need[:4])), (21, 32)) old_need = [math.ceil(2 / 3 * n) for n in (20, 18, 18, 20)] # the first campaign: every garden on the path check("first campaign: grooves / wide rake after (Astra's count)", (sum(old_need[:3]), sum(old_need)), (38, 52)) check("demonstrations", [p['name'] for p in P if p['demo']], ['The corridor', 'Nine squares', 'First ripple', 'First wave', 'Ichimatsu', 'First grooves', 'The finished ring', 'His pebbles', 'Wide blade', 'Two gates']) check("the corridor's par (quoted in the demonstration)", next(p['par'] for p in P if p['name'] == 'The corridor'), 3) check("no demonstration among the extras", any(p['demo'] and p['extra'] for p in P), False) check("every garden has a first hint", all(isinstance(p['hint'], str) and len(p['hint']) > 10 for p in P), True) check("every garden's par rakings stored", all(len(p['par_moves']) == p['at_par'] for p in P), True) CS = json.load(open('/home/claude/zen/out/campaign_stats.json')) specs = {k: v for k, v in CS.items() if isinstance(v, dict) and 'found' in v} check("campaign: generation specs (47, and one fallback)", len(specs), 48) check("campaign: 9x9 pebble gardens found / layouts tried / minutes", (specs['w7-p99']['found'], specs['w7-p99']['tried'], round(specs['w7-p99']['seconds'] / 60)), (0, 227, 16)) check("campaign: 8x8 pebble fallback found enough", specs['w7-p88']['found'] >= 2 and specs['w7-p88'].get('fallback_for') == 'w7-p99', True) import glob, pickle cand_keys = set() for f in glob.glob('/home/claude/zen/out/campaign_cache/w*.pkl'): for c in pickle.load(open(f, 'rb'))[1]: cand_keys.add((tuple(c['garden'].rows), tuple(c['garden'].gate), tuple(c['garden'].end) if c['garden'].end else None)) C = json.load(open('/home/claude/zen/out/campaign.json')) ngen = sum((tuple(e['garden']['rows']), tuple(e['garden']['gate']), tuple(e['garden']['end']) if e['garden']['end'] else None) in cand_keys for e in C) check("campaign: generated / teaching gardens / gardens in contrasting pairs", (ngen, len(C) - ngen, sum(1 for p in P if p['pair'])), (114, 34, 13)) check("campaign: exported in the same order", [e['name'] for e in C] == [p['name'] for p in P], True) from campaign import canon same_layout = collections.Counter(canon(from_dict(e['garden'])) for e in C) twins = sorted(sorted(e['name'] for e in C if canon(from_dict(e['garden'])) == k) for k, n in same_layout.items() if n > 1) check("campaign: no two gardens alike up to rotation and reflection, but the pairs that share a layout", twins, [['First grooves', 'Open ground'], ['His grooves', 'His pebbles'], ['Narrow rake', 'Wide blade']]) check("campaign: candidates / layouts sampled / minutes of computing", (sum(v['found'] for v in specs.values()), sum(v['tried'] for v in specs.values()), round(sum(v['seconds'] for v in specs.values()) / 60)), (235, 16437, 33)) check("campaign: about half the time on 9x9 pebbles", round(specs['w7-p99']['seconds'] / sum(v['seconds'] for v in specs.values()), 1), 0.5) check("campaign: world 7 pebble gardens are 8x8", sorted({(len(p['rows']), len(p['rows'][0])) for p in P if p['world'] == 7 and p['marks']}), [(8, 8)]) check("campaign: hints with only generic advice", sum('Par is' in p['hint'] for p in P), 0) check("campaign: template roles", sorted(collections.Counter(p['role'] for p in P).items()), [('application', 93), ('calm', 7), ('capstone', 7), ('combination', 15), ('intro', 13), ('pair', 6), ('trap', 7)]) mains = {w: [p['role'] for p in P if p['world'] == w and not p['extra']] for w in range(1, 8)} check("campaign: each world's main path opens with its introduction (a pair, or for world 7 an intro), has one trap, " "and ends calm, capstone", all(m[0] in ('pair', 'intro') and 'intro' in m and m.count('trap') == 1 and m[-2:] == ['calm', 'capstone'] for m in mains.values()), True) check("campaign: extras come after each world's main path", all( [p['extra'] for p in P if p['world'] == w] == sorted(p['extra'] for p in P if p['world'] == w) for w in range(1, 8)), True) ft = [p['trap'] for p in P if p['role'] == 'trap' and p['world'] <= 3] check("campaign: free traps: share of near-misses that miss the key observation (min, max)", (round(min(ft), 2), round(max(ft), 2)), (0.74, 0.94)) HS = json.load(open('/home/claude/zen/out/hint_stats.json')) hf = [h for h in HS if h['kind'] == 'free']; hs = [h for h in HS if h['kind'] == 'strict' and not h.get('trap')] check("hints: free gardens, median share of near-misses that get the hint wrong", (len(hf), round(statistics.median(h['miss'] for h in hf), 2)), (50, 0.5)) tr = collections.Counter((h['tier'], h['tier_after']) for h in hs) check("hints: strict gardens; need lookahead; then none / nested->one level / fewer steps", (len(hs), sum(h['tier'] >= 3 for h in hs), tr[(3, 1)] + tr[(3, 2)], tr[(4, 3)], sum(h['tier'] == h['tier_after'] == 3 and h['trials_after'] < h['trials'] for h in hs)), (80, 62, 16, 10, 36)) check("hints: no strict hint leaves lookahead as it was", sum(h['tier'] == h['tier_after'] and h['tier'] >= 3 and h['trials_after'] >= h['trials'] for h in hs), 0) check("hints: strict gardens without lookahead; made local / already local", (tr[(2, 1)], tr[(1, 1)]), (4, 14)) check("hints: end hints that say the cell has one way in", sorted(p['name'] for p in P if 'only one way in, so the rake' in p['hint']), ['First grooves', 'His grooves', 'His pebbles', 'Interrupted', 'The finished ring']) # (names follow layouts: out/garden_names.json) check("campaign: teaching gardens include 13 of the original 14", {p['name'] for p in P} >= {"Nine squares", "The pocket", "First stroke", "Calm", "Ripples", "Waves", "Ichimatsu", "The master's grooves", "The abbot's pebbles", "Wide rake", "Spiral", "Side gate", "Silver sand"}, True) # the abbot's taste vs the wide rake (exp_continuity.py): par rakings with a U-turn in open sand, free gardens of worlds 1-3 CT = json.load(open('/home/claude/zen/out/continuity.json')) check("continuity: free gardens; all par rakings graceful; mixed; none graceful", (CT['free_gardens'], CT['all_graceful'], CT['mixed'], CT['none_graceful']), (54, 11, 42, 1)) check("continuity: par rakings; with an open-sand U-turn", (CT['par_rakings'], CT['par_rakings_with_open_u']), (407, 327)) wv = next(r for r in CT['gardens'] if r['name'] == 'Waves') check("continuity: Waves' par rakings, and how many turn back in open sand", (wv['at_par'], wv['with_open_u']), (34, 32)) check("continuity: the one free garden with no graceful par raking is a hand-made one", [r['name'] for r in CT['gardens'] if r['with_open_u'] == r['at_par']], ['The pocket']) # the contrasting pairs (pairs.py), re-checked from the exported gardens PP = {p['name']: p for p in P} from zg import check_solution as _cs def g_of(p): marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()} clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()} return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None, nohairpin=2 if p['wide'] else 0, braced_start=p['wide'], end_side=p['end_side']) STEP = {"N": (-1, 0), "S": (1, 0), "E": (0, 1), "W": (0, -1)} def cells_of(p, ms): r, c = p['gate'][0], p['gate'][1]; out = [(r, c)] for ch in ms: r, c = r + STEP[ch][0], c + STEP[ch][1]; out.append((r, c)) return out pair_names = [("Plain stone", "First ripple"), ("Calm water", "First wave"), ("Open ground", "First grooves"), ("His grooves", "His pebbles"), ("Narrow rake", "Wide blade"), ("One gate", "Two gates")] check("pairs: sand cells of every pair garden (and the finished ring's)", sorted({sum(ch in '.hv=|' for r in PP[n]['rows'] for ch in r) for ab in pair_names + [("The finished ring",)] for n in ab}), [17, 18, 21, 22, 23]) for a, b in [("Plain stone", "First ripple"), ("Calm water", "First wave")]: check(f"pairs: every par raking of {a} breaks the rule added in {b}", any(_cs(g_of(PP[b]), cells_of(PP[a], ms)) for ms in PP[a]['par_moves']), False) check("pairs: every par raking of Narrow rake turns back in open sand", all(any(not br for *_, br in hairpin_list(g_of(PP['Narrow rake']), cells_of(PP['Narrow rake'], ms))) for ms in PP['Narrow rake']['par_moves']), True) check("pairs: Open ground's rakings; First grooves' grooves and rakings", (PP['Open ground']['rakings'], len(PP['First grooves']['clues']), PP['First grooves']['rakings']), (8, 1, 1)) check("pairs: His grooves' grooves; His pebbles' pebbles (pale, dark), at the grooves' cells; rakings", (len(PP['His grooves']['clues']), sorted(PP['His pebbles']['marks'].values()), set(PP['His pebbles']['marks']) == set(PP['His grooves']['clues']), PP['His pebbles']['rakings']), (2, ['B', 'S'], True, 1)) og, tg = PP['One gate'], PP['Two gates'] check("pairs: One gate has two wide-rake rakings; Two gates one, which is not One gate's par raking", (og['rakings'], tg['rakings'], turns_of(g_of(og), cells_of(tg, tg['par_moves'][0])) > og['par']), (2, 1, True)) check("pairs: which gardens of the wide and side pairs use the wide rake (Narrow rake, Wide blade, One gate, Two gates)", [PP[n]['wide'] for n in ("Narrow rake", "Wide blade", "One gate", "Two gates")], [False, True, True, True]) check("first campaign's big introductions, now applications: Waves; The master's grooves; The abbot's pebbles (sand cells)", [sum(ch in '.hv=|' for r in PP[n]['rows'] for ch in r) for n in ("Waves", "The master's grooves", "The abbot's pebbles")], [55, 47, 47]) check("side-gate gardens", sum(1 for p in P if p['end']), 19) small = solv = side = soft = medium = 0 for f in ('/home/claude/zen/out/test_side_pivot_seed1.log', '/home/claude/zen/out/test_side_pivot_seed2.log'): t = open(f).read() a = re.search(r'ok (\d+) bad (\d+) solvable (\d+) \(side gate (\d+), finished sand (\d+)\)', t) b = re.search(r'medium \(C vs CP-SAT\): ok (\d+) bad (\d+) \(counts and histograms compared on (\d+)\)', t) check(f"side/pivot cross-check log {f[-9:]}: no disagreements", (a.group(2), b.group(2)), ('0', '0')) small += int(a.group(1)); solv += int(a.group(3)); side += int(a.group(4)); soft += int(a.group(5)); medium += int(b.group(3)) check("side/pivot cross-check: small gardens; solvable; with a side gate; with finished sand; medium (counts compared)", (small, solv, side, soft, medium), (3192, 411, 132, 149, 120)) # garden-like stones vs single-cell stones (exp_sampler.py, 1,000 random 7x7 layouts each) Y = json.load(open('/home/claude/zen/out/sampler_yields.json'))['summary'] check("sampler layouts", (Y['garden']['layouts'], Y['single']['layouts']), (1000, 1000)) check("sampler stone cells", (round(Y['garden']['mean_stone_cells'], 1), round(Y['single']['mean_stone_cells'], 1)), (4.7, 2.0)) check("sampler solvable %", (round(100 * Y['garden']['solvable']), round(100 * Y['single']['solvable'])), (28, 39)) check("sampler usable free %", (round(100 * Y['garden']['graceful_free'], 1), round(100 * Y['single']['graceful_free'], 1)), (4.7, 3.8)) check("sampler wide-unique %", (round(100 * Y['garden']['wide_unique'], 1), round(100 * Y['single']['wide_unique'], 1)), (2.2, 2.7)) check("sampler side-unique %", (round(100 * Y['garden']['side_unique'], 1), round(100 * Y['single']['side_unique'], 1)), (1.8, 1.3)) check("sampler grooves median / lookahead %", (Y['garden']['median_grooves'], Y['single']['median_grooves'], round(100 * Y['garden']['grooves_lookahead']), round(100 * Y['single']['grooves_lookahead'])), (6, 6, 28, 84)) H = json.load(open('/home/claude/zen/out/hint_check.json')) check("hint check: routes, disagreements, timeouts", (H['routes'], H['bad'], H['states']['unknown']), (4440, 0, 0)) PG = {p['name']: p for p in P} check("hint check: no search ran out of budget", H['unknown_in'], []) check("hint check: stuck routes; rewinds proven nearest / unproven", (H['states']['stuck'], H['proven_rewinds'], H['unproven_rewinds']), (2011, 1989, 22)) check("hint check: the unproven rewinds are in three of the largest gardens (8x8 or 9x9)", sorted({(len(PG[n]['rows']), len(PG[n]['rows'][0])) for n in H['unproven_in']}), [(8, 8), (9, 9)]) check("hint check: unproven rewinds are in three gardens", len(set(H['unproven_in'])), 3) check("hint check: slowest analysis 0.7 s", round(H['max_ms'] / 1000, 1), 0.7) import re small = medium = 0 for f in ('/home/claude/zen/out/test_marks_seed1.log', '/home/claude/zen/out/test_marks_seed2.log'): t = open(f).read() a = re.search(r'ok (\d+) bad (\d+) solvable', t); b = re.search(r'medium \(C vs CP-SAT\): ok (\d+) bad (\d+)', t) check(f"marks cross-check log {f[-9:]}: no disagreements", (a.group(2), b.group(2)), ('0', '0')) small += int(a.group(1)); medium += int(b.group(1)) check("pebbles cross-check: small / medium gardens", (small, medium), (6000, 108)) tot = 0 for f in ('/home/claude/zen/out/test_grader_marks_seed7.log', '/home/claude/zen/out/test_grader_marks_seed11.log'): m = re.search(r'ok (\d+) bad (\d+)', open(f).read()) check(f"grader soundness log {f[-10:]}: no failures", m.group(2), '0'); tot += int(m.group(1)) print("grader soundness gardens:", tot) # poems (Presentation; Credits) PO = json.load(open('/home/claude/zen/poems/samon-poems.json')) AS = json.load(open('/home/claude/zen/poems/astra-samon-poem-selection.json')) pp = PO['poems']; used = list(PO['gardens'].values()) + [PO['practice']] DOC = open('/home/claude/zen/samon.md').read() check("poems: one for each garden and one for the practice garden, all different", (len(PO['gardens']), len(set(used)), set(PO['gardens']) == {p['name'] for p in P}), (148, 149, True)) check("poems: every text re-checked against a page that prints it", sum(pp[i]['reverified'].startswith('yes') and pp[i]['source_url'].startswith('https://') for i in used), 149) pc = collections.Counter(pp[i]['poet'] for i in used) check("poems: poets; Bashō, Teika, Buson, Rengetsu", (len(pc), pc['Matsuo Bashō'], pc['Fujiwara no Teika'], pc['Yosa Buson'], pc['Ōtagaki Rengetsu']), (25, 34, 23, 20, 13)) check("poems: Tsurayuki, Izumi Shikibu and Shiki have one each", (pc['Ki no Tsurayuki'], pc['Izumi Shikibu'], pc['Masaoka Shiki']), (1, 1, 1)) check("poems: doc states the counts", all(s in DOC for s in ("149 (one for each garden, and one for the practice garden) by 25 poets", "Bashō (34), Teika (23), Buson (20) and Rengetsu (13)")), True) anames = {l['name']: l['poem_id'] for l in AS['levels']} check("poems: Astra's selection covered 136 gardens", len(AS['levels']), 136) rev = [r for r in PO['about']['revisions'] if 'to' in r] check("poems: second and third passes: 25 swaps, each in force, to a poem used by that garden alone (Owl's hour took the capstone's)", (len(rev), all(PO['gardens'][r['garden']] == r['to'] for r in rev), len({r['to'] for r in rev}), sum('translation_repair' in r for r in PO['about']['revisions'])), (25, True, 25, 4)) check("poems: new gardens paired / poems new to the selection / pairings changed since Astra", (sum(g not in anames for g in PO['gardens']), sum(i not in AS['poems'] for i in used), sum(g in anames and anames[g] != PO['gardens'][g] for g in PO['gardens'])), (13, 23, 23)) check("poems: doc says 25 pairings were replaced in two further passes", "replaced 25 pairings judged weak" in DOC, True) check("poems: translations revised / new", (sum('revised' in pp[i]['translation_credit'] for i in used), sum('New translation' in pp[i]['translation_credit'] for i in used)), (25, 17)) check("poems: doc states translations revised (first pass)", "revised 22 of the translations" in DOC, True) check("poems: all written before 1900 (poets' death years or floruit; Shiki's by its year), and one poet who outlived it", (max(min(int(re.findall(r'\d{3,4}', pp[i]['author_dates'])[-1]), pp[i].get('composed', 9999)) for i in used) < 1900, sum(int(re.findall(r'\d{3,4}', pp[i]['author_dates'])[-1]) >= 1900 for i in used)), (True, 1)) G1 = PO['gardens'] check("poems: first garden = sweeping the temple garden; new pond then old pond at the first ripple stone; wave pair = the two Kiyotaki drafts; last main-path garden = the sand at Kehi", (pp[G1['The corridor']]['japanese'], pp[G1['Plain stone']]['japanese'], pp[G1['First ripple']]['japanese'], pp[G1['Calm water']]['japanese'], pp[G1['First wave']]['japanese'], pp[G1['Silver sand']]['japanese'], [p['name'] for p in P if p['world'] == 7 and not p['extra']][-1], P[0]['name'], PG['First ripple']['role'], PG['First wave']['pair'], PG['Calm water']['pair'], PG['Plain stone']['pair']), ('庭掃て出ばや寺に散柳', '新池や蛙とびこむ音もなし', '古池や蛙飛びこむ水の音', '清滝や波に塵なき夏の月', '清滝や波に散り込む青松葉', '月清し遊行のもてる砂の上', 'Silver sand', 'The corridor', 'intro', 'band', 'band', 'ripple')) check("poems: the first and last poems quoted as translated", all(pp[G1[g]]['translation'].replace('\n', ' / ') in DOC for g in ('The corridor', 'Silver sand')), True) # the daily garden (Progression; Prototype) DY = json.load(open('/home/claude/zen/out/daily_gardens.json')) DS = json.load(open('/home/claude/zen/out/daily_stats.json')) from campaign import canon as canon_ camp_canon = {canon_(from_dict(e['garden'])) for e in C} def garden_of_entry(e): return from_dict({"rows": e["rows"], "gate": e["gate"], "clues": e["clues"], "end": e["end"], "nohairpin": 2 if e["wide"] else 0, "braced_start": bool(e["wide"]), "end_side": e["end_side"], "marks": e["marks"], "name": e["name"]}) check("daily: a year of gardens, even days free and odd days strict", (len(DY), all((e['rakings'] > 1) == (k % 2 == 0) for k, e in enumerate(DY))), (366, True)) check("daily: none shares a layout with the campaign, and no two share one", (sum(canon_(garden_of_entry(e)) in camp_canon for e in DY), len({canon_(garden_of_entry(e)) for e in DY})), (0, 366)) check("daily: sizes 5x5 to 8x8", (min(len(e['rows']) * len(e['rows'][0]) for e in DY), max(len(e['rows']) for e in DY), max(len(e['rows'][0]) for e in DY)), (25, 8, 8)) check("daily: every mechanic appears", all(DS[k] > 0 for k in ('with_ripple', 'with_band', 'with_grooves', 'with_pebbles', 'wide', 'side_gate', 'closed_ring')), True) check("daily: generation took about 8 minutes on two cores", round(sum(v['seconds'] for v in DS['specs'].values()) / 60 / 2), 8) check("daily: doc states the year and the timing", all(x in DOC for x in ("a year of 366 gardens", "took 8 minutes of generation on two cores")), True) import random as random_ smp = random_.Random(7).sample(range(366), 12) okd = [] for k in smp: # a sample re-derived: the exact count, CP-SAT par, the intended raking at par e = DY[k]; g = garden_of_entry(e); r = g.solve(mode=0, cap=10**9); d = [tuple(c) for c in e['intended']] okd.append(r['nsol'] == e['rakings'] and optimize(g)[0] == e['par'] and check_solution(g, d) and turns_of(g, d) == e['par'] and r['hist'].get(e['par']) == e['at_par'] == len(e['par_moves'])) check("daily: a sample of 12 re-derived (count, par, intended raking, par rakings)", all(okd), True) # the sound (Presentation; Prototype): the recording carried in the page, as the document describes it import base64 as base64_, subprocess as subprocess_ SP = json.load(open('/home/claude/zen/out/sound_pieces.json')) ST = json.load(open('/home/claude/zen/sound/tracks.json')) check("sound: one recording, Shika no Tōne, credited to Araki Kodō III with its Commons source, public domain", (len(SP), SP[0]['title'][:13], 'Araki Kodō III' in SP[0]['player'], SP[0]['source'], SP[0]['license'].startswith('public domain'), SP[0]['title'] == ST['tracks'][0]['title']), (1, 'Shika no Tōne', True, 'https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg', True, True)) mp3 = base64_.b64decode(SP[0]['b64']) open('/tmp/claude-0/verify_piece.mp3', 'wb').write(mp3) # (probed from a file: piped input hides the duration and bit rate) pr = json.loads(subprocess_.run(['ffprobe', '-hide_banner', '-loglevel', 'error', '-show_entries', 'format=duration,bit_rate:stream=codec_name,channels', '-of', 'json', '/tmp/claude-0/verify_piece.mp3'], capture_output=True, check=True).stdout) check("sound: mono MP3, 6 min 15 s, 32 kbps, 1.5 MB, as stated", (pr['streams'][0]['codec_name'], pr['streams'][0]['channels'], round(float(pr['format']['duration'])), round(int(pr['format']['bit_rate']) / 1000), round(len(mp3) / 1e6, 1)), ('mp3', 1, 375, 32, 1.5)) check("sound: doc states the encoding, the source and the credit", all(x in DOC for x in ("re-encoded as mono MP3 at 32 kbps (1.5 MB)", "File:Shikanotoone_new.ogg", "Araki Kodō III (1879--1935) on Victor 13029")), True) check("sound: the page carries the recording and its credits", all(x in open('/home/claude/zen/proto/samon.html').read() for x in ('class="piece"', 'data-title="Shika no Tōne', 'data:audio/mpeg;base64,' + SP[0]['b64'][:64])), True) print("FAILS:", fails) src/xc_cpsat.py import random, sys, time from zg import Garden from zcp import count_solutions, optimize cases = [ Garden([".....", ".....", ".....", ".....", "....."], (4, 0, "W")), Garden(["......", "..#...", "......", "...#..", "......"], (4, 0, "S")), Garden(["......", ".O....", "......", "....#.", "......"], (0, 5, "E")), Garden(["......", "..hhh.", "..hhh.", "......", "......"], (4, 2, "S")), Garden(["......", ".O....", "......", "....#.", "......"], (0, 5, "E"), nohairpin=True), Garden(["......", "..#...", "......", "...#..", "......", "......"], (5, 0, "W"), nohairpin=True), Garden(["......", "......", "......", "......"], (3, 0, "W"), clues={(1, 2): "NS", (2, 4): "EW"}), Garden(["......", "......", "......", "......"], (3, 0, "W"), end=(0, 5)), ] for g in cases: t0 = time.time(); c = g.solve(mode=0, cap=10**9); t1 = time.time() n, h, st = count_solutions(g, workers=1); t2 = time.time() b = g.solve(mode=1, cap=10**9) opt = optimize(g) print(f"C={c['nsol']} ({t1-t0:.2f}s) CPSAT={n} {st} ({t2-t1:.1f}s) hist_eq={c['hist']==h} Cmin={b['best']['turns'] if 'best' in b else None} CPmin={opt[0]} {opt[1]}") src/xc_cpsat2.py import time from zg import Garden from zcp import count_solutions, optimize cases = [ Garden(["......", "......", "......", "......", "......"], (4, 0, "W"), nohairpin=2), Garden(["......", "..#...", "......", "...#..", "......"], (4, 0, "S"), nohairpin=2), Garden(["......", "......", "......", "......", "......"], (4, 0, "W"), braced_turns=True), Garden([".......", "..#....", ".......", "....#..", ".......", "......."], (5, 0, "W"), braced_turns=True), Garden(["......", ".O....", "......", "....#.", "......"], (0, 5, "E"), nohairpin=2), Garden([".......", ".......", "..#....", ".......", "....#..", "......."], (5, 0, "W"), nohairpin=2, braced_turns=True), ] for g in cases: t0 = time.time(); c = g.solve(mode=0, cap=10**9); t1 = time.time() n, h, st = count_solutions(g, workers=1); t2 = time.time() b = g.solve(mode=1, cap=10**9) opt = optimize(g) print(f"C={c['nsol']} ({t1-t0:.2f}s) CPSAT={n} {st} ({t2-t1:.1f}s) hist_eq={c['hist']==h} Cmin={b['best']['turns'] if 'best' in b else None} CPmin={opt[0]} {opt[1]}") src/xc_stats.py import sys, random, time sys.path.insert(0, '/home/claude/zen/src') from zg import Garden, brute_force, check_solution src = open('/home/claude/zen/src/test_crosscheck.py').read().split('ok = bad = 0')[0] ns = {} exec(src.replace('random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 1)', f'random.seed({sys.argv[1]})'), ns) rg = ns['random_garden'] tot = nz = bad = 0; maxsol = 0 featcount = {'ring':0,'zone':0,'clue':0,'nohp':0,'end':0} t0 = time.time() while time.time() - t0 < float(sys.argv[2]): g = rg() if g is None or len(g.sand_cells()) > 18: continue try: g.ring_sets() except ValueError: continue res = g.solve(mode=0, cap=10**9) n, h = brute_force(g) if res['nsol'] != n or res['hist'] != h or not all(check_solution(g, s['draw']) for s in res['sols']): bad += 1; print('BAD', g, res['nsol'], n, flush=True) tot += 1 if n: nz += 1; maxsol = max(maxsol, n) if g.ring_sets(): featcount['ring'] += 1 if any(ch in 'hv' for row in g.rows for ch in row): featcount['zone'] += 1 if g.clues: featcount['clue'] += 1 if g.nohairpin: featcount['nohp'] += 1 if g.end: featcount['end'] += 1 print('total', tot, 'solvable', nz, 'bad', bad, 'max sols', maxsol, featcount, flush=True) src/zcp.py — CP-SAT model of the one-stroke raking puzzle (independent of the C solver). """zcp.py: CP-SAT model of the one-stroke raking puzzle (independent of the C solver). Used for (1) cross-validating solution counts, (2) proving 'par' (minimum turns), and (3) multi-objective trade-offs (turns vs hairpins). Arcs are in *raking* order: the stroke runs from the rake-start P to the gate cell, then exits. """ from __future__ import annotations from ortools.sat.python import cp_model from zg import Garden, DIRS, DR, DC, OPP PERP = {"N": "EW", "S": "EW", "E": "NS", "W": "NS"} class Model: def __init__(self, g: Garden, nohairpin=None): self.g = g m = self.m = cp_model.CpModel() cells = g.sand_cells() self.cells = cells idx = self.idx = {c: i for i, c in enumerate(cells)} n = len(cells) D = n gate = (g.gate[0], g.gate[1]) arcs = [] self.x = {} # (cell, d) -> literal for rake moving from cell in direction d for c in cells: for d in DIRS: nb = (c[0] + DR[d], c[1] + DC[d]) if nb in idx: lit = m.NewBoolVar(f"x{c}{d}") self.x[(c, d)] = lit arcs.append((idx[c], idx[nb], lit)) self.start = {} for c in cells: if g.end is not None and c != tuple(g.end): continue if g.braced_start and not any(g.pivot(c[0] + DR[d], c[1] + DC[d]) for d in DIRS): continue lit = m.NewBoolVar(f"s{c}") self.start[c] = lit arcs.append((D, idx[c], lit)) exit_lit = m.NewConstant(1) arcs.append((idx[gate], D, exit_lit)) m.AddCircuit(arcs) # AddCircuit only covers nodes that have arcs: a sand cell with none (walled in by stones and finished sand) # must still make the garden infeasible, so every cell needs exactly one way in (an arc, or being the start) into = {i: [] for i in range(n)} for a, b, lit in arcs: if b < n: into[b].append(lit) for i in range(n): m.Add(sum(into[i]) == 1) false = m.NewConstant(0) # direction literals self.ind = {} self.outd = {} for c in cells: for d in DIRS: prev = (c[0] - DR[d], c[1] - DC[d]) self.ind[(c, d)] = self.x.get((prev, d), false) self.outd[(c, d)] = self.x.get((c, d), false) if c == gate: self.outd[(c, g.gate[2])] = exit_lit # straightness indicators (with a side gate, the stroke enters its cell through the wall: a bend there is a turn) side_in = None if g.end is not None and g.end_side: side_in = (tuple(g.end), OPP[g.end_side]) one = m.NewConstant(1) self.straight = {} for c in cells: for d in DIRS: a, b = (one if (c, d) == side_in else self.ind[(c, d)]), self.outd[(c, d)] s = m.NewBoolVar(f"st{c}{d}") m.AddImplication(s, a) m.AddImplication(s, b) m.AddBoolOr([a.Not(), b.Not(), s]) self.straight[(c, d)] = s self.turns = m.NewIntVar(0, n, "turns") m.Add(self.turns == (n if side_in else n - 1) - sum(self.straight.values())) # hairpins: rake moves a into u, b from u to v, then -a out of v self.hp = [] self.hp_braced = [] for (u, b), lit in self.x.items(): v = (u[0] + DR[b], u[1] + DC[b]) for a in PERP[b]: self.hp_braced.append(g.pivot(u[0] + DR[a], u[1] + DC[a]) and g.pivot(v[0] + DR[a], v[1] + DC[a])) i1, i3 = self.ind[(u, a)], self.outd[(v, OPP[a])] h = m.NewBoolVar(f"hp{u}{b}{a}") m.AddImplication(h, i1) m.AddImplication(h, lit) m.AddImplication(h, i3) m.AddBoolOr([i1.Not(), lit.Not(), i3.Not(), h]) self.hp.append(h) self.hairpins = m.NewIntVar(0, n, "hairpins") m.Add(self.hairpins == sum(self.hp)) nh = g.nohairpin if nohairpin is None else nohairpin if nh == 1: m.Add(self.hairpins == 0) elif nh == 2: for h, braced in zip(self.hp, self.hp_braced): if not braced: m.Add(h == 0) for c in g.noturn_cells(): m.Add(sum(self.straight[(c, d)] for d in DIRS) + (self.start[c] if c in self.start else 0) == 1) # ripple rings: exactly one entry into the ring set (from outside, or by starting there) for ring in g.ring_sets(): S = set(ring) entries = [] for c in S: for d in DIRS: prev = (c[0] - DR[d], c[1] - DC[d]) if prev in idx and prev not in S: entries.append(self.x[(prev, d)]) if c in self.start: entries.append(self.start[c]) m.Add(sum(entries) == 1) # wave bands / ichimatsu / given grooves: required & forbidden edges def edge_used(c, d): nb = (c[0] + DR[d], c[1] + DC[d]) if c == gate and d == g.gate[2]: return None # the exit itself: always used if nb not in idx: return false return self.x[(c, d)] + self.x[(nb, OPP[d])] for c, need in g.zone_requirements().items(): for d in need: e = edge_used(c, d) if e is not None: m.Add(e == 1) for c, want in g.clues.items(): for d in DIRS: e = edge_used(c, d) if e is None: if d not in want: m.Add(false == 1) # clue forbids the exit direction at the gate: infeasible continue m.Add(e == (1 if d in want else 0)) # shape marks: 'S' = straight through (never the rake start); 'B' = a turn (never the rake start) for c, t in g.marks.items(): m.Add(sum(self.straight[(c, d)] for d in DIRS) == (1 if t == "S" else 0)) if c in self.start: m.Add(self.start[c] == 0) def path_from(self, solver): """Recover the rake path (P ... gate) from a solved model.""" g = self.g nxt = {} for (c, d), lit in self.x.items(): if solver.BooleanValue(lit): nxt[c] = (c[0] + DR[d], c[1] + DC[d]) start = [c for c, lit in self.start.items() if solver.BooleanValue(lit)][0] p = [start] while p[-1] in nxt: p.append(nxt[p[-1]]) return p class _Counter(cp_model.CpSolverSolutionCallback): def __init__(self, model: Model, limit): super().__init__() self.model, self.limit, self.n, self.hist = model, limit, 0, {} def on_solution_callback(self): self.n += 1 t = self.Value(self.model.turns) self.hist[t] = self.hist.get(t, 0) + 1 if self.n >= self.limit: self.StopSearch() def count_solutions(g: Garden, limit=10**7, workers=1, time_limit=600): mdl = Model(g) solver = cp_model.CpSolver() solver.parameters.enumerate_all_solutions = True solver.parameters.num_workers = workers solver.parameters.max_time_in_seconds = time_limit cb = _Counter(mdl, limit) st = solver.Solve(mdl.m, cb) return cb.n, cb.hist, solver.StatusName(st) def optimize(g: Garden, objective="turns", nohairpin=None, time_limit=120, workers=2, fix=None): """Minimise turns (default) or hairpins; returns (value, status, rake path, turns, hairpins). `fix`: dict like {'hairpins': 0} or {'turns_le': 12} adds constraints first.""" mdl = Model(g, nohairpin=nohairpin) if fix: if "hairpins" in fix: mdl.m.Add(mdl.hairpins == fix["hairpins"]) if "hairpins_le" in fix: mdl.m.Add(mdl.hairpins <= fix["hairpins_le"]) if "turns_le" in fix: mdl.m.Add(mdl.turns <= fix["turns_le"]) if "turns_eq" in fix: mdl.m.Add(mdl.turns == fix["turns_eq"]) if objective is not None: obj = {"turns": mdl.turns, "hairpins": mdl.hairpins}[objective] mdl.m.Minimize(obj) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = time_limit solver.parameters.num_workers = workers st = solver.Solve(mdl.m) name = solver.StatusName(st) if st in (cp_model.OPTIMAL, cp_model.FEASIBLE): val = int(solver.ObjectiveValue()) if objective is not None else solver.Value(mdl.turns) return (val, name, mdl.path_from(solver), solver.Value(mdl.turns), solver.Value(mdl.hairpins)) return (None, name, None, None, None) src/zg.py — Garden model, solver driver, and independent brute-force checker. """zg.py: garden model, solver driver, and independent brute-force checker. Garden text symbols: . plain sand # stone O ripple stone (its 8 neighbours must be raked as one arc) h sand in a horizontal wave band (lines run the band's full width, unbroken) v sand in a vertical wave band = sand in an ichimatsu block raked horizontally; | ... raked vertically (blocks are maximal same-symbol rectangles; within one block runs are atomic like wave bands) Given grooves ("the master's raking") are a dict {(r, c): 'EW'|'NS'|'NE'|...}. Gate = (r, c, side): the rake stroke ends at sand cell (r, c) and leaves through the wall on `side`. """ from __future__ import annotations import subprocess import os from dataclasses import dataclass, field HERE = os.path.dirname(os.path.abspath(__file__)) SOLVER = os.path.join(HERE, "zgsolve") DIRS = "NESW" DR = {"N": -1, "E": 0, "S": 1, "W": 0} DC = {"N": 0, "E": 1, "S": 0, "W": -1} OPP = {"N": "S", "S": "N", "E": "W", "W": "E"} SAND = set(".hv=|") @dataclass class Garden: rows: list[str] gate: tuple[int, int, str] clues: dict = field(default_factory=dict) # (r,c) -> 'EW' etc end: tuple[int, int] | None = None nohairpin: int = 0 # 0: hairpins allowed; 1: forbidden; 2: allowed only when braced against wall/stone (pivot) braced_turns: bool = False # turns allowed only in cells touching a wall or stone braced_start: bool = False # the rake must be set down in a cell touching a wall or stone end_side: str | None = None # two-gate gardens: wall side of the second gate; a bend where the stroke enters there counts as a turn name: str = "" marks: dict = field(default_factory=dict) # (r,c) -> 'S' (stroke runs straight through) or 'B' (stroke turns here) @property def H(self): return len(self.rows) @property def W(self): return len(self.rows[0]) def sym(self, r, c): if 0 <= r < self.H and 0 <= c < self.W: return self.rows[r][c] return None def is_sand(self, r, c): s = self.sym(r, c) return s is not None and s in SAND def blocked(self, r, c): return not self.is_sand(r, c) def pivot(self, r, c): """Something a wide-rake U-turn (or the rake's start) can be braced against: a wall or a stone. Sand the abbot has already raked ('R', a finished ring) is not walkable, but it is no pivot: turning against it would rake it.""" s = self.sym(r, c) return s is None or (s not in SAND and s != "R") def noturn_cells(self): if not self.braced_turns: return [] return [(r, c) for (r, c) in self.sand_cells() if all(self.is_sand(r + DR[d], c + DC[d]) for d in DIRS)] def sand_cells(self): return [(r, c) for r in range(self.H) for c in range(self.W) if self.is_sand(r, c)] def ring_sets(self): rings = [] for r in range(self.H): for c in range(self.W): if self.rows[r][c] == "O": ring = [(r - 1, c), (r - 1, c + 1), (r, c + 1), (r + 1, c + 1), (r + 1, c), (r + 1, c - 1), (r, c - 1), (r - 1, c - 1)] for rr, cc in ring: if not self.is_sand(rr, cc): raise ValueError(f"ripple stone at {(r, c)} lacks full sand ring") rings.append(ring) return rings def zone_requirements(self): """Required directions implied by wave bands / ichimatsu blocks: adjacent same-band cells along the band orientation must be joined by the stroke.""" req = {} for r in range(self.H): for c in range(self.W): s = self.rows[r][c] if s in "h=": for d in "EW": if self.sym(r + DR[d], c + DC[d]) == s: req.setdefault((r, c), set()).add(d) elif s in "v|": for d in "NS": if self.sym(r + DR[d], c + DC[d]) == s: req.setdefault((r, c), set()).add(d) return req def to_input(self, mode=0, cap=2, nodelimit=None, bound=None, stream=False): lines = [f"{self.H} {self.W}"] for row in self.rows: lines.append("".join("." if ch in SAND else "R" if ch == "R" else "#" for ch in row)) gr, gc, gd = self.gate lines.append(f"gate {gr} {gc} {gd}") if self.end is not None: lines.append(f"end {self.end[0]} {self.end[1]}") if self.end_side: lines.append(f"endside {self.end_side}") lines.append(f"nohairpin {int(self.nohairpin)}") lines.append(f"mode {mode}") lines.append(f"cap {cap}") if nodelimit: lines.append(f"nodelimit {nodelimit}") if bound is not None: lines.append(f"bound {bound}") if stream: lines.append("stream 1") if self.marks: lines.append(f"shape {len(self.marks)} " + " ".join(f"{r} {c} {t}" for (r, c), t in sorted(self.marks.items()))) if self.braced_start: ec = [(r, c) for (r, c) in self.sand_cells() if any(self.pivot(r + DR[d], c + DC[d]) for d in DIRS)] lines.append(f"endcells {len(ec)} " + " ".join(f"{r} {c}" for r, c in ec)) nt = self.noturn_cells() if nt: lines.append(f"noturn {len(nt)} " + " ".join(f"{r} {c}" for r, c in nt)) req = self.zone_requirements() reqlines = [] cells = set(req) | set(self.clues) for (r, c) in sorted(cells): need = set(req.get((r, c), set())) forb = set() if (r, c) in self.clues: g = self.clues[(r, c)] need |= set(g) forb |= set(DIRS) - set(g) reqlines.append(f"{r} {c} {''.join(sorted(need)) or '-'} {''.join(sorted(forb)) or '-'}") lines.append(f"req {len(reqlines)}") lines.extend(reqlines) rings = self.ring_sets() lines.append(f"rings {len(rings)}") for ring in rings: lines.append(f"{len(ring)} " + " ".join(f"{r} {c}" for r, c in ring)) return "\n".join(lines) + "\n" def solve(self, mode=0, cap=2, nodelimit=None, bound=None, timeout=600, stream=False): """stream=True returns every solution found (in 'sols'), not just the first 16.""" inp = self.to_input(mode=mode, cap=cap, nodelimit=nodelimit, bound=bound, stream=stream) p = subprocess.run([SOLVER], input=inp, capture_output=True, text=True, timeout=timeout) if p.returncode != 0: raise RuntimeError(p.stderr + "\n" + inp) return parse_output(p.stdout, self.W) def parse_output(out, W): res = {"sols": [], "hist": {}, "hphist": {}} for line in out.strip().split("\n"): parts = line.split() k = parts[0] if k in ("nsol", "complete", "capped", "nodes"): res[k] = int(parts[1]) elif k in ("hist", "hphist"): res[k] = {int(a): int(b) for a, b in (x.split(":") for x in parts[1:])} elif k in ("best", "worst", "sol"): turns, n = int(parts[1]), int(parts[2]) cells = [divmod(int(x), W) for x in parts[3:3 + n]] entry = {"turns": turns, "draw": cells, "rake": list(reversed(cells))} if k == "sol": res["sols"].append(entry) else: res[k] = entry return res def move_dir(a, b): dr, dc = b[0] - a[0], b[1] - a[1] for d in DIRS: if DR[d] == dr and DC[d] == dc: return d raise ValueError((a, b)) def path_moves(g: Garden, draw): """Moves in drawing order, starting with the entry move through the gate.""" moves = [OPP[g.gate[2]]] for a, b in zip(draw, draw[1:]): moves.append(move_dir(a, b)) return moves def turns_of(g: Garden, draw): """Turns along a raking, counting the turn out through the veranda gate and, with a side gate, the bend where the stroke enters through it (in drawing order: the walk's last move against the way out through the side gate).""" m = path_moves(g, draw) t = sum(1 for a, b in zip(m, m[1:]) if a != b) if g.end is not None and g.end_side and len(draw) > 1 and tuple(draw[-1]) == tuple(g.end): t += m[-1] != g.end_side return t def hairpin_list(g: Garden, draw): """Hairpins as (turn cell 1, turn cell 2, bottom direction, braced?) in drawing order.""" m = path_moves(g, draw) out = [] for k in range(2, len(draw) + 1): a, b = m[k - 2], m[k - 1] c = m[k] if k < len(m) else None if c is not None and c == OPP[a] and b != a: u, v = draw[k - 2], draw[k - 1] braced = g.pivot(u[0] + DR[a], u[1] + DC[a]) and g.pivot(v[0] + DR[a], v[1] + DC[a]) out.append((u, v, a, braced)) return out def hairpins_of(g: Garden, draw): return len(hairpin_list(g, draw)) def glyphs(g: Garden, draw): """Groove glyph per cell as a pair of directions (sorted string); rake-start cell gets 1 direction.""" out = {} n = len(draw) for i, cell in enumerate(draw): ds = set() if i == 0: ds.add(g.gate[2]) else: ds.add(move_dir(cell, draw[i - 1])) if i + 1 < n: ds.add(move_dir(cell, draw[i + 1])) out[cell] = "".join(d for d in DIRS if d in ds) return out def check_solution(g: Garden, draw) -> bool: """Independent verification of a solution against every rule (used to cross-check the C solver).""" sand = set(g.sand_cells()) if len(draw) != len(sand) or set(draw) != sand: return False if draw[0] != (g.gate[0], g.gate[1]): return False for a, b in zip(draw, draw[1:]): if abs(a[0] - b[0]) + abs(a[1] - b[1]) != 1: return False if g.end is not None and draw[-1] != tuple(g.end): return False if g.braced_start: r, c = draw[-1] if not any(g.pivot(r + DR[d], c + DC[d]) for d in DIRS): return False gl = glyphs(g, draw) for cell, need in g.zone_requirements().items(): if not need <= set(gl[cell]): return False for cell, want in g.clues.items(): if set(gl[cell]) != set(want): return False for cell, t in g.marks.items(): ds = set(gl[cell]) straight = ds in ({"E", "W"}, {"N", "S"}) if len(ds) != 2 or straight != (t == "S"): return False pos = {c: i for i, c in enumerate(draw)} for ring in g.ring_sets(): ps = sorted(pos[c] for c in ring) if ps[-1] - ps[0] != len(ring) - 1: return False hp = hairpin_list(g, draw) if g.nohairpin == 1 and hp: return False if g.nohairpin == 2 and any(not br for *_, br in hp): return False m = path_moves(g, draw) nt = set(g.noturn_cells()) for k, cell in enumerate(draw[:-1]): if cell in nt and m[k] != m[k + 1]: return False return True def brute_force(g: Garden, limit=None): """Plain exhaustive DFS (no pruning beyond self-avoidance); returns (count, turn histogram).""" sand = set(g.sand_cells()) start = (g.gate[0], g.gate[1]) n = len(sand) count = 0 hist = {} path = [start] seen = {start} def rec(): nonlocal count if len(path) == n: if check_solution(g, path): count += 1 t = turns_of(g, path) hist[t] = hist.get(t, 0) + 1 return r, c = path[-1] for d in DIRS: nxt = (r + DR[d], c + DC[d]) if nxt in sand and nxt not in seen: seen.add(nxt) path.append(nxt) rec() path.pop() seen.discard(nxt) rec() return count, hist def ascii_solution(g: Garden, draw): """Render the raked pattern with box-drawing glyphs.""" box = {"EW": "─", "NS": "│", "ES": "┌", "SW": "┐", "NE": "└", "NW": "┘", "N": "╵", "E": "╶", "S": "╷", "W": "╴"} gl = glyphs(g, draw) out = [] for r in range(g.H): line = [] for c in range(g.W): if (r, c) in gl: key = "".join(sorted(gl[(r, c)], key=DIRS.index)) # normalise key ordering for lookup k2 = {"NE": "NE", "ES": "ES", "SW": "SW", "NW": "NW", "EW": "EW", "NS": "NS"} kk = key if len(kk) == 2: for cand in box: if len(cand) == 2 and set(cand) == set(kk): kk = cand line.append(box.get(kk, "?")) else: line.append("●" if g.rows[r][c] == "O" else "■") out.append("".join(line)) return "\n".join(out) src/zgsolve.c — Exact solver for one-stroke Zen-garden raking puzzles. /* zgsolve.c: exact solver for one-stroke Zen-garden raking puzzles. * * A garden is an H x W grid of sand cells and blocked cells. A solution is a * Hamiltonian path over the sand cells which ends at the gate cell (and exits * through the gate in direction gateDir). We search in *drawing order*: from * the gate inward, which is the reverse of the raking order; turn and hairpin * counts are invariant under reversal. * * Optional constraints: * - required/forbidden directions per cell (encodes wave bands & given grooves), * - ring sets (cells that must be raked as one unbroken arc), * - no-hairpin ("wide rake": no move triple a,b,-a with b perpendicular to a), braced U-turns allowed in mode 2: * braced = both cells beyond are wall or stone ('#'); finished sand ('R': not walkable) is no pivot, * - fixed rake-start cell (two-gate gardens); with `endside d`, the stroke enters that cell through a side gate * in wall side d, and a bend there counts as a turn (as leaving through the veranda gate does), * - shape marks per cell: 'S' = the stroke runs straight through, 'B' = it turns (either way); * a marked cell can never be the rake's start. * * Modes: 0 = count solutions (up to cap), 1 = branch-and-bound min turns, * 2 = count solutions with turns <= bound. * * Input (stdin): see parse() below. Output: key/value lines for the Python driver. * Build: gcc -O3 -march=native -Wall -Wextra -o zgsolve zgsolve.c */ #include #include #include #include typedef unsigned __int128 u128; #define MAXN 128 #define MAXR 64 #define MAXSOL 16 static const int DR[4] = {-1, 0, 1, 0}; /* N E S W */ static const int DC[4] = {0, 1, 0, -1}; static inline int opp(int d) { return (d + 2) & 3; } static int H, W, N; static int isSoft[MAXN]; /* 'R': sand the abbot has finished; not walkable, and not a pivot */ static int isSand[MAXN]; static int nb[MAXN][4]; static int piv[MAXN][4]; /* 1 if the cell beyond in direction d is a wall or stone: something a U-turn can pivot on */ static u128 sandMask, blackMask, notCol0, notColLast; static int gateCell, gateDir, endCell = -1, endSideDir = -1, noHairpin = 0; static u128 endMask; static int endMaskSet = 0; static int reqm[MAXN], forbm[MAXN], noTurn[MAXN]; static int shapeReq[MAXN], nShape = 0, shapeCells[MAXN]; /* 1 = straight, 2 = turn */ static int streamAll = 0; /* print every solution as it is found (instead of storing the first MAXSOL) */ static int nRings, ringSize[MAXR], ringCount[MAXR]; static uint64_t ringOf[MAXN]; static long long cap = 2, nsol = 0, nodes = 0, nodeLimit = 4000000000LL; static int aborted = 0, hitCap = 0; static int mode = 0, turnBound = 1 << 20; static long long hist[2 * MAXN + 8], hpHist[MAXN + 8], ubHist[MAXN + 8]; static int path[MAXN], plen = 0; static int bestTurns = 1 << 20, bestPath[MAXN], bestLen = 0; static int worstTurns = -1, worstPath[MAXN], worstLen = 0; static int nStored = 0, stored[MAXSOL][MAXN], storedLen[MAXSOL], storedTurns[MAXSOL]; static int moveSeq[MAXN + 2]; /* moves in drawing order; moveSeq[0] = entry move into gate cell */ static inline int pc(u128 x) { return __builtin_popcountll((uint64_t)x) + __builtin_popcountll((uint64_t)(x >> 64)); } static inline u128 bit(int i) { return ((u128)1) << i; } static inline u128 spread(u128 m) { return m | ((m << 1) & notCol0) | ((m >> 1) & notColLast) | (m << W) | (m >> W); } static int countHairpins(int *unbraced) { /* moveSeq[0..plen-1]: moveSeq[k] is the move INTO path[k] (k=0: entry into gate cell). Triple (a,b,c) entering path[k-2], path[k-1], path[k]: turn cells are path[k-2] and path[k-1]. */ int h = 0, ub = 0; for (int k = 2; k < plen; k++) { int a = moveSeq[k - 2], b = moveSeq[k - 1], c = moveSeq[k]; if (c == opp(a) && b != a) { h++; if (!(piv[path[k - 2]][a] && piv[path[k - 1]][a])) ub++; } } *unbraced = ub; return h; } static void record(int turns) { nsol++; if (turns < 2 * MAXN + 8) hist[turns]++; int ub; int hp = countHairpins(&ub); if (hp < MAXN + 8) hpHist[hp]++; if (ub < MAXN + 8) ubHist[ub]++; if (streamAll) { printf("sol %d %d", turns, plen); for (int i = 0; i < plen; i++) printf(" %d", path[i]); printf("\n"); } else if (nStored < MAXSOL) { memcpy(stored[nStored], path, plen * sizeof(int)); storedLen[nStored] = plen; storedTurns[nStored] = turns; nStored++; } if (turns < bestTurns) { bestTurns = turns; memcpy(bestPath, path, plen * sizeof(int)); bestLen = plen; } if (turns > worstTurns) { worstTurns = turns; memcpy(worstPath, path, plen * sizeof(int)); worstLen = plen; } } /* v: head cell; back: direction from v toward previous path cell (gateDir at the gate); * m1: move into v; m2: move before that (-1 if none); unv: unvisited sand (excluding v); * turns: turns at cells before v; active: rings started but not finished. */ static void dfs(int v, int back, int m1, int m2, u128 unv, int turns, uint64_t active) { if (aborted) return; if (++nodes > nodeLimit) { aborted = 1; return; } if (mode == 1 && turns >= bestTurns) return; if (mode == 2 && turns > turnBound) return; if (forbm[v] & (1 << back)) return; int rem = reqm[v] & ~(1 << back); int remc = __builtin_popcount(rem); if (remc > 1) return; if (unv == 0) { if (remc != 0) return; if (endCell >= 0 && v != endCell) return; if (!((endMask >> v) & 1)) return; int tf = turns + (endSideDir >= 0 && m1 != endSideDir); /* the bend where the stroke enters through a side gate */ if (mode == 2 && tf > turnBound) return; record(tf); if (mode != 1 && nsol >= cap) { aborted = 1; hitCap = 1; } return; } u128 vb = bit(v), allowed = unv | vb; /* connectivity of remaining sand through the head */ u128 reach = vb, nr; for (;;) { nr = spread(reach) & allowed; if (nr == reach) break; reach = nr; } if (reach != allowed) return; /* degree check: every unvisited cell except the eventual rake-start needs >= 2 usable neighbours */ u128 a = (allowed << 1) & notCol0, b = (allowed >> 1) & notColLast, c = allowed << W, d = allowed >> W; u128 any = a | b | c | d; if (unv & ~any) return; u128 ge2 = (a & b) | (a & c) | (a & d) | (b & c) | (b & d) | (c & d); u128 le1 = unv & ~ge2; if (le1 & ~endMask) return; if (pc(le1) > 1) return; if (!(unv & endMask)) return; /* checkerboard parity: remaining cells alternate colours starting with the opposite of the head */ int R = pc(unv), headBlack = (int)((blackMask >> v) & 1), nBlack = pc(unv & blackMask); int expBlack = headBlack ? R / 2 : (R + 1) / 2; if (nBlack != expBlack) return; if (endCell >= 0 && ((unv >> endCell) & 1) == 0) return; /* shape marks on unvisited cells: a straight mark needs both neighbours on one axis usable, a turn mark one on each axis */ for (int i = 0; i < nShape; i++) { int s = shapeCells[i]; if (!((unv >> s) & 1)) continue; int u[4]; for (int d = 0; d < 4; d++) u[d] = nb[s][d] >= 0 && ((allowed >> nb[s][d]) & 1); if (shapeReq[s] == 1 ? !((u[0] && u[2]) || (u[1] && u[3])) : !((u[0] || u[2]) && (u[1] || u[3]))) return; } int forced = remc == 1 ? __builtin_ctz(rem) : -1; for (int e = 0; e < 4; e++) { if (forced >= 0 && e != forced) continue; if (forbm[v] & (1 << e)) continue; int w = nb[v][e]; if (w < 0 || !((unv >> w) & 1)) continue; if (noHairpin && m2 >= 0 && e == opp(m2) && m1 != m2) { /* hairpin with turn cells u = v - m1 and v; braced iff both cells beyond in direction m2 are wall or stone */ if (noHairpin == 1) continue; int u = nb[v][opp(m1)]; if (!(piv[u][m2] && piv[v][m2])) continue; } if (noTurn[v] && e != m1) continue; if (shapeReq[v] == 1 && e != m1) continue; if (shapeReq[v] == 2 && e == m1) continue; uint64_t rw = ringOf[w], newActive = active; for (uint64_t t = rw; t; t &= t - 1) { int k = __builtin_ctzll(t); ringCount[k]++; if (ringCount[k] == ringSize[k]) newActive &= ~(1ULL << k); else newActive |= (1ULL << k); } if (!(newActive & ~ringOf[w])) { path[plen] = w; moveSeq[plen] = e; plen++; dfs(w, opp(e), e, m1, unv & ~bit(w), turns + (e != m1), newActive); plen--; } for (uint64_t t = rw; t; t &= t - 1) ringCount[__builtin_ctzll(t)]--; if (aborted) return; } } static int dirOf(char ch) { switch (ch) { case 'N': return 0; case 'E': return 1; case 'S': return 2; case 'W': return 3; } return -1; } static int maskOf(const char *s) { int m = 0; if (strcmp(s, "-") == 0) return 0; for (; *s; s++) { int d = dirOf(*s); if (d < 0) { fprintf(stderr, "bad dir %c\n", *s); exit(2); } m |= 1 << d; } return m; } static void printPath(const char *tag, int *p, int len, int turns) { printf("%s %d %d", tag, turns, len); for (int i = 0; i < len; i++) printf(" %d", p[i]); printf("\n"); } /* Read exactly n fields or fail loudly, naming the key being parsed. */ #define READ(n, ...) do { if (scanf(__VA_ARGS__) != (n)) { fprintf(stderr, "malformed input for key '%s'\n", key); exit(2); } } while (0) int main(void) { char key[64], buf[256]; if (scanf("%d %d", &H, &W) != 2) return 2; N = H * W; if (N > MAXN) { fprintf(stderr, "garden too large\n"); return 2; } for (int r = 0; r < H; r++) { if (scanf("%255s", buf) != 1 || (int)strlen(buf) != W) { fprintf(stderr, "bad row %d\n", r); return 2; } for (int c = 0; c < W; c++) { isSand[r * W + c] = buf[c] == '.'; isSoft[r * W + c] = buf[c] == 'R'; } } gateCell = -1; while (scanf("%63s", key) == 1) { if (!strcmp(key, "gate")) { int r, c; char ds[8]; READ(3, "%d %d %7s", &r, &c, ds); gateCell = r * W + c; gateDir = dirOf(ds[0]); } else if (!strcmp(key, "end")) { int r, c; READ(2, "%d %d", &r, &c); endCell = r < 0 ? -1 : r * W + c; } else if (!strcmp(key, "endside")) { char ds[8]; READ(1, "%7s", ds); endSideDir = dirOf(ds[0]); if (endSideDir < 0) { fprintf(stderr, "bad endside\n"); return 2; } } else if (!strcmp(key, "nohairpin")) READ(1, "%d", &noHairpin); else if (!strcmp(key, "mode")) READ(1, "%d", &mode); else if (!strcmp(key, "bound")) READ(1, "%d", &turnBound); else if (!strcmp(key, "cap")) READ(1, "%lld", &cap); else if (!strcmp(key, "nodelimit")) READ(1, "%lld", &nodeLimit); else if (!strcmp(key, "req")) { int k; READ(1, "%d", &k); for (int i = 0; i < k; i++) { int r, c; char rs[8], fs[8]; READ(4, "%d %d %7s %7s", &r, &c, rs, fs); reqm[r * W + c] |= maskOf(rs); forbm[r * W + c] |= maskOf(fs); } } else if (!strcmp(key, "endcells")) { int k; READ(1, "%d", &k); endMaskSet = 1; for (int i = 0; i < k; i++) { int r, c; READ(2, "%d %d", &r, &c); endMask |= bit(r * W + c); } } else if (!strcmp(key, "shape")) { int k; READ(1, "%d", &k); for (int i = 0; i < k; i++) { int r, c; char ts[8]; READ(3, "%d %d %7s", &r, &c, ts); if (r < 0 || r >= H || c < 0 || c >= W || (ts[0] != 'S' && ts[0] != 'B')) { fprintf(stderr, "bad shape mark %d %d %s\n", r, c, ts); exit(2); } shapeReq[r * W + c] = ts[0] == 'S' ? 1 : 2; } } else if (!strcmp(key, "stream")) READ(1, "%d", &streamAll); else if (!strcmp(key, "noturn")) { int k; READ(1, "%d", &k); for (int i = 0; i < k; i++) { int r, c; READ(2, "%d %d", &r, &c); noTurn[r * W + c] = 1; } } else if (!strcmp(key, "rings")) { READ(1, "%d", &nRings); if (nRings > MAXR) { fprintf(stderr, "too many rings\n"); return 2; } for (int k = 0; k < nRings; k++) { READ(1, "%d", &ringSize[k]); for (int i = 0; i < ringSize[k]; i++) { int r, c; READ(2, "%d %d", &r, &c); ringOf[r * W + c] |= 1ULL << k; } } } else { fprintf(stderr, "unknown key %s\n", key); return 2; } } if (gateCell < 0 || !isSand[gateCell]) { fprintf(stderr, "gate must be a sand cell\n"); return 2; } sandMask = blackMask = notCol0 = notColLast = 0; for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) { int v = r * W + c; if (isSand[v]) sandMask |= bit(v); if (((r + c) & 1) == 0) blackMask |= bit(v); if (c != 0) notCol0 |= bit(v); if (c != W - 1) notColLast |= bit(v); for (int d = 0; d < 4; d++) { int rr = r + DR[d], cc = c + DC[d]; int inside = rr >= 0 && rr < H && cc >= 0 && cc < W; nb[v][d] = (inside && isSand[rr * W + cc]) ? rr * W + cc : -1; piv[v][d] = !inside || (!isSand[rr * W + cc] && !isSoft[rr * W + cc]); } } if (!endMaskSet) endMask = sandMask; if (endCell >= 0) endMask &= bit(endCell); for (int v = 0; v < N; v++) if (shapeReq[v]) { if (!isSand[v]) { fprintf(stderr, "shape mark on a non-sand cell\n"); return 2; } endMask &= ~bit(v); /* the rake's start has one connection: never a marked cell */ shapeCells[nShape++] = v; } path[0] = gateCell; moveSeq[0] = opp(gateDir); plen = 1; uint64_t active = 0; for (uint64_t t = ringOf[gateCell]; t; t &= t - 1) { int k = __builtin_ctzll(t); ringCount[k]++; if (ringCount[k] < ringSize[k]) active |= 1ULL << k; } dfs(gateCell, gateDir, opp(gateDir), -1, sandMask & ~bit(gateCell), 0, active); printf("nsol %lld\n", nsol); printf("complete %d\n", (aborted && !hitCap) ? 0 : 1); printf("capped %d\n", hitCap); printf("nodes %lld\n", nodes); printf("hist"); for (int t = 0; t < 2 * MAXN + 8; t++) if (hist[t]) printf(" %d:%lld", t, hist[t]); printf("\nhphist"); for (int t = 0; t < MAXN + 8; t++) if (hpHist[t]) printf(" %d:%lld", t, hpHist[t]); printf("\nubhist"); for (int t = 0; t < MAXN + 8; t++) if (ubHist[t]) printf(" %d:%lld", t, ubHist[t]); printf("\n"); if (bestLen) printPath("best", bestPath, bestLen, bestTurns); if (worstLen) printPath("worst", worstPath, worstLen, worstTurns); for (int i = 0; i < nStored; i++) printPath("sol", stored[i], storedLen[i], storedTurns[i]); return 0; } The prototype and its tests (proto/ ) proto/build.py — Build the prototype: samon.html (artifact page body: no doctype/head, per the artifact page contract) """Build the prototype: samon.html (artifact page body: no doctype/head, per the artifact page contract) and samon.standalone.html (a complete document, for self-hosting).""" import json import sys sys.path.insert(0, '/home/claude/zen/src') from poems import attach from sound_pieces import pieces_html import os G = json.load(open('/home/claude/zen/out/proto_gardens.json')) practice = attach(G) # each garden's poem (poems/samon-poems.json), and the practice garden's DAILY_PATH = '/home/claude/zen/out/daily_gardens.json' # a year of daily gardens (src/daily.py); the page does without if absent DAILY = json.load(open(DAILY_PATH)) if os.path.exists(DAILY_PATH) else [] if not DAILY: print('warning: no daily gardens (out/daily_gardens.json missing)') s = open('/home/claude/zen/proto/samon.template.html').read() assert s.count('__GARDENS__') == 1 and s.count('__PRACTICE_POEM__') == 1 and s.count('__DAILY__') == 1 and s.count('__PIECES__') == 1 s = s.replace('__GARDENS__', json.dumps(G, separators=(',', ':'), ensure_ascii=False)).replace( '__PRACTICE_POEM__', json.dumps(practice, separators=(',', ':'), ensure_ascii=False)).replace( '__DAILY__', json.dumps(DAILY, separators=(',', ':'), ensure_ascii=False)).replace( '__PIECES__', pieces_html()) # the recordings (out/sound_pieces.json; sound/make_sound_bundle.py) open('/home/claude/zen/proto/samon.html', 'w').write(s) print(len(s), 'bytes: samon.html') split = s.index('') head, body = s[:split], s[split:] SOCIAL = ('\n' '\n' '\n\n\n' '\n' '\n\n\n' '\n' '\n\n\n' '\n\n' '\n') doc = ('\n\n\n\n' '\n' + SOCIAL + head + '\n\n' + body + '\n\n') open('/home/claude/zen/proto/samon.standalone.html', 'w').write(doc) print(len(doc), 'bytes: samon.standalone.html') proto/samon.template.html Samon Samon砂紋 DawnThe corridor ▾ Sound on Volume Haptics on 静 original Turns0 Par0 Step back Restore Start over Ask the abbot Watch Rake Next garden PracticeNo goal Rake Stones 3 tines Smooth Assists Preview grooves Checkerboard Cut-off sand

[How to play]

[Drag the monk in from the gate and cover every cell of sand without crossing your path; when you let go, he sets down his rake and walks backwards out, raking. Tap a footprint to step back to it, or the gate to start over; after raking, tap any cell to walk back to it. Fewer turns is calmer: rake at par for the abbot's seal. Each raking brings a poem, then the next garden. Keyboard: arrows walk, Backspace steps back, R starts over, H asks the abbot, Enter goes on.]

[Drag anywhere to rake. With Stones selected, tap to place a stone or lift one; the sand under a stone keeps whatever was there. Smooth rakes the garden flat again.]

__PIECES__ proto/test/crisp.py — Zoomed crops of finished rakings (device pixels, 1:1) and frame timing during the reveal. """Zoomed crops of finished rakings (device pixels, 1:1) and frame timing during the reveal.""" import asyncio, json, sys from playwright.async_api import async_playwright G = json.load(open('/home/claude/zen/out/proto_gardens.json')) URL = 'file:///home/claude/zen/proto/test/index.html' async def run(b, name, w, dpr, tag): page = await (await b.new_context(viewport={'width': w, 'height': 1000}, device_scale_factor=dpr)).new_page() await page.goto(URL); await page.wait_for_timeout(300) i = next(k for k, g in enumerate(G) if g['name'] == name) await page.evaluate(f"window.__samon.load({i})") res = await page.evaluate("window.__samon.G && document.getElementById('garden').width") await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended']) # frame timing during the reveal times = await page.evaluate("""() => new Promise(done => { window.__samon.rakeNow(); const ts = []; let last = performance.now(); const f = now => { ts.push(now - last); last = now; if (ts.length < 120) requestAnimationFrame(f); else done(ts); }; requestAnimationFrame(f); })""") await page.wait_for_timeout(7000) el = await page.query_selector('#garden') await el.screenshot(path=f'/home/claude/zen/proto/test/crisp-{tag}.png') ts = sorted(times[1:]) print(tag, name, 'canvas px', res, f'frame ms median {ts[len(ts)//2]:.1f} p95 {ts[int(len(ts)*0.95)]:.1f} max {ts[-1]:.1f}') async def main(): async with async_playwright() as p: b = await p.chromium.launch() await run(b, 'Spiral', 1200, 2, 'desktop2x') await run(b, 'Calm', 390, 3, 'phone3x') await b.close() asyncio.run(main()) proto/test/frames.py — Capture frames of the raking reveal (and the walk-in sand) for a visual check. """Capture frames of the raking reveal (and the walk-in sand) for a visual check.""" import asyncio, json, sys from playwright.async_api import async_playwright G = json.load(open('/home/claude/zen/out/proto_gardens.json')) URL = 'file:///home/claude/zen/proto/test/index.html' NAME = sys.argv[1] if len(sys.argv) > 1 else 'Calm' SCHEME = sys.argv[2] if len(sys.argv) > 2 else 'light' async def main(): async with async_playwright() as p: b = await p.chromium.launch() page = await (await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme=SCHEME)).new_page() await page.goto(URL); await page.wait_for_timeout(300) i = next(k for k, g in enumerate(G) if g['name'] == NAME) await page.evaluate(f"window.__samon.load({i})") cells = G[i]['intended'] await page.evaluate("(w) => window.__samon.setPath(w)", cells[:len(cells) * 2 // 3]) el = await page.query_selector('#garden') await el.screenshot(path=f'/home/claude/zen/proto/test/frame-{NAME}-{SCHEME}-walk.png') await page.evaluate("(w) => window.__samon.setPath(w)", cells) await page.evaluate("window.__samon.rakeNow()") t = 0 for k, ms in enumerate([700, 1300, 1600]): await page.wait_for_timeout(ms); t += ms await el.screenshot(path=f'/home/claude/zen/proto/test/frame-{NAME}-{SCHEME}-{k}.png') await page.wait_for_timeout(5000) await el.screenshot(path=f'/home/claude/zen/proto/test/frame-{NAME}-{SCHEME}-done.png') print(await page.evaluate("[window.__samon.mode, document.getElementById('status').textContent]")) await b.close() asyncio.run(main()) proto/test/fuzz.py — Gesture fuzzer for the prototype: random drags, taps, keys, button presses and links (shared rakings, carried progress) for a while, against invariants. """Gesture fuzzer for the prototype: random drags, taps, keys, button presses and links (shared rakings, carried progress) for a while, against invariants. Invariants, after every action: the walk is a legal chain of sand cells from the gate with no repeats; the mode, view and the controls agree (Next only when done, Rake only in a finishable daily walk, Restore only with something to restore, Step back and Start over disabled when the daily raking is final); the status is empty or in editorial brackets; the saved progress parses and, after a reload, brings the garden and its draft back; the sound engine keeps a bounded number of timers; no page errors, no console errors. Usage: python3 proto/test/fuzz.py [seconds=180] [seed=1] """ import asyncio, json, random, sys, time from playwright.async_api import async_playwright URL = 'file:///home/claude/zen/proto/test/index.html' SECS = float(sys.argv[1]) if len(sys.argv) > 1 else 180 SEED = int(sys.argv[2]) if len(sys.argv) > 2 else 1 R = random.Random(SEED) STATE_JS = """() => { const s = window.__samon, G = s.G, el = id => document.getElementById(id); const vis = id => { const e = el(id); return !!e && !e.hidden && getComputedStyle(e).display !== 'none'; }; return { mode: s.mode, view: s.view, path: s.path.slice(), W: G.W, H: G.H, rows: G.rows, gate: G.gateCell, nSand: G.nSand, daily: !!G.daily, status: el('status').textContent, next: vis('next'), rakeDay: vis('rakeDay'), restore: vis('restore'), undoOff: el('undo').disabled, restartOff: el('restart').disabled, timers: s.sound.timers, soundOn: s.sound.on, actions: [...document.querySelectorAll('#actions button')].map(b => b.textContent), poem: s.poem.state, demo: s.demoRunning, idx: window.__samon.G.name, shared: !!s.share.shared, hash: location.hash }; }""" def legal(st): P, W, rows = st['path'], st['W'], st['rows'] if not P: return True, '' if P[0] != st['gate']: return False, f'walk starts at {P[0]}, not the gate {st["gate"]}' if len(set(P)) != len(P): return False, 'walk repeats a cell' for i, k in enumerate(P): r, c = divmod(k, W) if rows[r][c] in '#OR': return False, f'walk stands on {rows[r][c]!r} at {(r, c)}' if i and abs(k // W - P[i - 1] // W) + abs(k % W - P[i - 1] % W) != 1: return False, f'walk jumps from {P[i - 1]} to {k}' return True, '' def consistent(st): if st['mode'] not in ('draw', 'reveal', 'done', 'demo'): return f'mode {st["mode"]!r}' if st['view'] not in ('puzzle', 'practice', 'poems'): return f'view {st["view"]!r}' if st['view'] == 'puzzle': if st['status'] and not (st['status'].startswith('[') and st['status'].endswith(']')): return f'status not bracketed: {st["status"][:60]!r}' if st['next'] and st['mode'] != 'done': return 'Next shown outside done' if st['rakeDay'] and not (st['daily'] and st['mode'] == 'draw' and len(st['path']) == st['nSand']): return 'Rake shown when the daily walk is not complete' if st['mode'] == 'done' and len(st['path']) != st['nSand']: return f'done with {len(st["path"])} of {st["nSand"]} cells' if st['daily'] and st['mode'] == 'done' and not st['shared'] and not (st['undoOff'] and st['restartOff']): return 'daily raking final but Step back / Start over enabled' if st['shared'] and st['mode'] != 'done': return f'a shared raking on show but mode {st["mode"]!r}' if st['hash'] and not st['shared'] and not st['hash'].startswith('#p='): return f'a hash left over: {st["hash"][:30]!r}' if st['timers'] > 60: return f'{st["timers"]} sound timers' return '' async def main(): fails, log = [], [] async with async_playwright() as p: b = await p.chromium.launch() ctx = await b.new_context(viewport={'width': 420, 'height': 900}, device_scale_factor=1) page = await ctx.new_page() errors = [] page.on('pageerror', lambda e: errors.append('pageerror: ' + str(e))) page.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' and 'Failed to load resource' not in m.text else None) await page.goto(URL); await page.wait_for_timeout(300) await page.evaluate("localStorage.setItem('samon-open-all', '1'); localStorage.setItem('samon-started', '1'); localStorage.setItem('samon-sound-hint', '1')") await page.reload(); await page.wait_for_timeout(300) await page.evaluate("window.__samon.poemTiming = {delay: 150, hold: 700, fade: 120}") ngardens = await page.evaluate("window.__samon.share.layouts().campaign.length") async def rect(): return await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()") async def check(what): st = await page.evaluate(STATE_JS) ok, why = legal(st) bad = ('' if ok else why) or consistent(st) if bad: fails.append((what, bad, st['idx'], st['mode'], st['view'])) print('FAIL', what, '->', bad, '| garden', st['idx'], st['mode'], st['view']) print(' last actions:', ' | '.join(log[-12:])) if errors: fails.append((what, errors[:])) print('FAIL', what, '-> errors', errors[:3]); errors.clear() return st t0 = time.time(); n = 0 while time.time() - t0 < SECS: n += 1 kind = R.choices(['drag', 'tap', 'key', 'button', 'pip', 'pill', 'reload', 'wait', 'link'], weights=[34, 14, 16, 14, 7, 5, 3, 7, 4])[0] try: if kind == 'drag': L, T, W, H = await rect() x, y = L + R.uniform(-0.1, 1.1) * W, T + R.uniform(-0.1, 1.1) * H await page.mouse.move(x, y); await page.mouse.down() vx, vy = R.uniform(-60, 60), R.uniform(-60, 60) for _ in range(R.randint(1, 14)): if R.random() < 0.3: vx, vy = R.uniform(-80, 80), R.uniform(-80, 80) x = min(max(x + vx + R.uniform(-8, 8), L - 60), L + W + 60); y = min(max(y + vy + R.uniform(-8, 8), T - 60), T + H + 60) await page.mouse.move(x, y, steps=R.randint(1, 4)) await page.mouse.up() log.append(f'drag@{x - L:.0f},{y - T:.0f}') elif kind == 'tap': L, T, W, H = await rect() x, y = L + R.uniform(-0.05, 1.05) * W, T + R.uniform(-0.05, 1.05) * H await page.mouse.click(x, y); log.append(f'tap@{x - L:.0f},{y - T:.0f}') elif kind == 'key': k = R.choice(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowRight', 'Backspace', 'r', 'h', 'Enter', 'Escape', 'm']) await page.focus('#garden'); await page.keyboard.press(k); log.append('key ' + k) elif kind == 'button': SEL = '.veranda button, #actions button, .assists button, #mute, #soundMore, #soundPanel button, #nav .fold, .help summary, #orig, .poem .card' ids = await page.evaluate(f"[...document.querySelectorAll('{SEL}')].filter(e => !e.hidden && !e.disabled && e.offsetParent !== null).map((e, i) => e.id || (e.className + '#' + i))") if ids: pick = R.randrange(len(ids)); which = ids[pick] els = await page.query_selector_all(SEL) vis = [e for e in els if await e.is_visible() and await e.is_enabled()] if vis: e = vis[min(pick, len(vis) - 1)] await e.click(timeout=2000); log.append('button ' + which) elif kind == 'pip': pips = await page.query_selector_all('#nav .pip:not(:disabled)') if pips: e = R.choice(pips); await e.scroll_into_view_if_needed(); await e.click(timeout=2000); log.append('pip') elif kind == 'pill': pills = await page.query_selector_all('#nav .pill') if pills: e = R.choice(pills); await e.scroll_into_view_if_needed(); await e.click(timeout=2000); log.append('pill ' + (await e.text_content() or '')[:14]) if R.random() < 0.5: await page.evaluate("window.__samon.G && window.__samon.load(window.__samon.G.daily ? 0 : Math.floor(Math.random() * 20))") elif kind == 'reload': before = await page.evaluate(STATE_JS) await page.reload(); await page.wait_for_timeout(400) await page.evaluate("window.__samon.poemTiming = {delay: 150, hold: 700, fade: 120}") after = await page.evaluate(STATE_JS) if before['view'] == 'puzzle' and before['mode'] == 'draw' and len(before['path']) > 1 and after['view'] == 'puzzle' and after['idx'] == before['idx'] and after['path'] != before['path'] and not after['demo']: # (a walk of one footprint, the gate, is not kept: it is no walk) fails.append(('reload', f'draft lost: {len(before["path"])} -> {len(after["path"])}', before['idx'])) print('FAIL reload -> draft lost', before['idx'], len(before['path']), len(after['path'])) log.append('reload') elif kind == 'link': # a shared raking, today's garden, or carried progress, by its hash what = R.choice(['raking', 'raking', 'daily', 'progress', 'bad']) if what == 'raking': gi = R.randrange(ngardens) code = await page.evaluate(f"(() => {{ const s = window.__samon, here = s.G.name; s.load({gi}); const c = s.share.code(s.G.intended); return c; }})()") await page.evaluate(f"location.hash = '#r={code}'"); await page.wait_for_timeout(300); log.append(f'link raking {gi}') elif what == 'daily': await page.evaluate("location.hash = '#daily'"); await page.wait_for_timeout(300); log.append('link daily') elif what == 'progress': code = await page.evaluate("window.__samon.share.progress()") await page.evaluate(f"location.hash = '#p={code}'"); await page.wait_for_timeout(300); log.append(f'link progress ({len(code)})') if R.random() < 0.7: btn = await page.query_selector('#actions button:has-text("Take it over")') if R.random() < 0.5 else await page.query_selector('#actions button:has-text("Leave it")') if btn: await btn.click(timeout=2000); log.append('link answered') else: await page.evaluate("location.hash = '#r=' + Math.random().toString(36).slice(2)"); await page.wait_for_timeout(300); log.append('link bad') else: await page.wait_for_timeout(R.randint(100, 900)); log.append('wait') except Exception as e: # a click that could not land (an element gone mid-way) is not a page failure log.append(f'({kind}: {type(e).__name__})') await check(f'#{n} {kind}') if len(log) > 60: del log[:-60] prog = await page.evaluate("(() => { try { return JSON.stringify(JSON.parse(localStorage.getItem('samon-progress') || '{}')).length; } catch (e) { return -1; } })()") if prog < 0: fails.append(('progress', 'does not parse')) print(f'{n} actions in {time.time() - t0:.0f} s, progress {prog} chars, gardens {ngardens}; fails: {len(fails)}') await b.close() sys.exit(1 if fails else 0) asyncio.run(main()) proto/test/grey_shot.py import asyncio, json from playwright.async_api import async_playwright G = json.load(open('/home/claude/zen/out/proto_gardens.json')) async def main(): async with async_playwright() as p: b = await p.chromium.launch() page = await (await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2)).new_page() await page.goto('file:///home/claude/zen/proto/test/index.html'); await page.wait_for_timeout(300) i = next(k for k, g in enumerate(G) if g['name'] == 'Spiral') await page.evaluate(f"window.__samon.load({i})") await page.click('#hint'); await page.click('#hint') await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended']) await page.evaluate("window.__samon.rakeNow()"); await page.wait_for_timeout(7000) await page.screenshot(path='/home/claude/zen/proto/test/shot-grey.png', clip={'x': 0, 'y': 0, 'width': 390, 'height': 760}) await b.close() asyncio.run(main()) proto/test/gt_check.py — Check the page's own hint analysis against the C solver on random legal routes. """Check the page's own hint analysis against the C solver on random legal routes. For each route: can it still reach par? can it still be finished? if not, which is the nearest footprint from which it can? (and is the par rewind point exact?) Ground truth: the C solver, with the route fixed by exact groove clues.""" import asyncio, json, random, sys, time sys.path.insert(0, '/home/claude/zen/src') from zg import Garden from playwright.async_api import async_playwright URL = 'file:///home/claude/zen/proto/test/index.html' P = json.load(open('/home/claude/zen/out/proto_gardens.json')) DIRS = {(-1, 0): 'N', (0, 1): 'E', (1, 0): 'S', (0, -1): 'W'} N_WALKS = int(sys.argv[1]) if len(sys.argv) > 1 else 40 def garden_of(p, extra=None): clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()} clues.update(extra or {}) marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()} return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None, nohairpin=2 if p['wide'] else 0, braced_start=p['wide'], end_side=p['end_side']) def d(a, b): return DIRS[(b[0] - a[0], b[1] - a[1])] def prefix_clues(p, cells): out = {} for i in range(len(cells) - 1): ds = {p['gate'][2] if i == 0 else d(cells[i], cells[i - 1]), d(cells[i], cells[i + 1])} out[cells[i]] = ''.join(sorted(ds)) return out cache = {} def truth(p, cells, kind): k = (p['name'], tuple(cells), kind) if k not in cache: g = garden_of(p, prefix_clues(p, cells)) r = g.solve(mode=0, cap=1) if kind == 'comp' else g.solve(mode=2, bound=p['par'], cap=1) assert r['complete'] or r['capped'], 'solver incomplete' cache[k] = r['nsol'] >= 1 return cache[k] async def main(): # the flags the page assumes reproduce the exported counts for p in P: r = garden_of(p).solve(mode=0, cap=10**7) assert r['nsol'] == p['rakings'], (p['name'], r['nsol'], p['rakings']) async with async_playwright() as pw: b = await pw.chromium.launch() page = await (await b.new_context(viewport={'width': 390, 'height': 900})).new_page() await page.goto(URL); await page.wait_for_timeout(300) stats = {'par': 0, 'finish': 0, 'stuck': 0, 'unknown': 0}; bad = 0; tmax = 0; proven_back = 0; unproven_back = 0 unknown_in, unproven_in, slowest = [], [], '' rng = random.Random(1) for i, p in enumerate(P): nsand = sum(ch in '.hv=|' for row in p['rows'] for ch in row) sols = garden_of(p).solve(mode=0, cap=3000, stream=True)['sols'] # prefixes of real rakings: never stuck for w in range(2 * N_WALKS): if w % 2 == 0: maxlen = rng.randint(2, nsand) cells = await page.evaluate(f"window.__samon.randomWalk({i}, {rng.randrange(10**9)}, {maxlen})") cells = [tuple(c) for c in cells] else: s_ = rng.choice(sols)['draw'] cells = [tuple(c) for c in s_[:rng.randint(1, len(s_))]] t0 = time.time() a = await page.evaluate("""([i, cells]) => { const t = performance.now(); const a = window.__samon.analyzeFor(i, cells); return {state: a.state, lcp: a.lcp, back: a.back ?? null, proven: a.proven ?? null, ms: performance.now() - t}; }""", [i, cells]) if a['ms'] > tmax: tmax, slowest = a['ms'], p['name'] stats[a['state']] += 1 if a['state'] == 'unknown': unknown_in.append(p['name']) n = len(cells) gpar, gcomp = truth(p, cells, 'par'), truth(p, cells, 'comp') ok = True # the par rewind point is exact: cells[:lcp] can reach par, cells[:lcp+1] cannot (when lcp < n) ok &= truth(p, cells[:a['lcp']], 'par') and (a['lcp'] == n or not truth(p, cells[:a['lcp'] + 1], 'par')) if a['state'] == 'par': ok &= gpar else: ok &= not gpar if a['state'] == 'finish': ok &= gcomp if a['state'] == 'stuck': ok &= not gcomp gback = next(k for k in range(n - 1, 0, -1) if truth(p, cells[:k], 'comp')) if a['proven']: ok &= a['back'] == gback; proven_back += 1 else: ok &= truth(p, cells[:a['back']], 'comp') and a['back'] <= gback; unproven_back += 1; unproven_in.append(p['name']) if not ok: bad += 1; print('BAD', p['name'], cells, a, 'truth par/comp', gpar, gcomp) print('states', stats, 'bad', bad, f'max analysis time {tmax:.0f} ms ({slowest})', 'stuck rewinds proven/unproven', proven_back, unproven_back) print('unknown in', unknown_in, 'unproven rewinds in', sorted(set(unproven_in))) json.dump({'routes': sum(stats.values()), 'states': stats, 'bad': bad, 'max_ms': round(tmax), 'proven_rewinds': proven_back, 'unproven_rewinds': unproven_back, 'slowest': slowest, 'unknown_in': unknown_in, 'unproven_in': unproven_in}, open('/home/claude/zen/out/hint_check.json', 'w'), indent=1) await b.close() asyncio.run(main()) proto/test/run.py — Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals; """Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals; Restore after a rewind and after Start over; the abbot's hints (first hint, sketched groove, 'no longer at par' with its rewind, 'stuck' with its rewind); the grey seal; unlocking; assists; the poems and the scroll of poems earned; the daily garden; drag intent (drift, the grip beyond the walls, arcs, reversals, wobble); sound; sharing (a raking as a code in the URL's hash, progress carried between devices, the share text); the practice garden; screenshots in light and dark.""" import asyncio, json, math, os, sys from playwright.async_api import async_playwright sys.path.insert(0, '/home/claude/zen/src') from zg import Garden, check_solution, turns_of G = json.load(open('/home/claude/zen/out/proto_gardens.json')) URL = 'file:///home/claude/zen/proto/test/index.html' SHOT = '/home/claude/zen/proto/test/' fails = [] def garden_of(p, braced=True): marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()} clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()} return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None, nohairpin=2 if p['wide'] else 0, braced_start=p['wide'] and braced, end_side=p['end_side']) def expect(cond, what): print(('ok ' if cond else 'FAIL ') + what) if not cond: fails.append(what) async def centers(page, cells): return await page.evaluate("""(path) => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); const s = r.width / cv.width; const g = window.__samon.geom(); return path.map(([rr, cc]) => [r.left + (g.ox + cc * g.cs + g.cs / 2) * s, r.top + (g.oy + rr * g.cs + g.cs / 2) * s]); }""", cells) async def drag(page, cells): pts = await centers(page, cells) await page.mouse.move(*pts[0]); await page.mouse.down() for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3) await page.mouse.up() async def quiet(page, **t): # poems off (they would carry the page on to the next garden), unless a test wants them await page.evaluate("(t) => { window.__samon.poemTiming = t; }", t or {'delay': 3600000}) async def state(page): return await page.evaluate("() => ({mode: window.__samon.mode, len: window.__samon.path.length, turns: window.__samon.turnsOf(), " "status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''), " "restore: !document.getElementById('restore').hidden, seal: getComputedStyle(document.getElementById('seal')).display, " "ghosts: window.__samon.ghosts.size, actions: [...document.querySelectorAll('#actions button')].map(b => b.textContent)})") async def main(): async with async_playwright() as p: b = await p.chromium.launch() errors = [] ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page() page.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None) page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(300) await quiet(page) st = await state(page) expect(st['seal'] == 'none' and not st['restore'] and not await page.query_selector('#rake'), 'seal and Restore hidden at load; no Rake button') # 0. a first visit: the first garden is raked once by the monk (walking in, then raking out) and reset for the player; # then the monk waits in the gateway, a ring beckons, and a ghostly finger shows the drag onboard = """() => ({beckon: !document.getElementById('beckon').hidden, finger: !document.getElementById('finger').hidden, anim: document.getElementById('finger').getAnimations().length, status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''), started: localStorage.getItem('samon-started')})""" g0 = G[0] expect(g0['demo'] and G[1]['demo'] and not G[2]['demo'], 'the first two gardens have demonstrations, the first real garden (third) none') await page.wait_for_function("window.__samon.mode === 'demo'", timeout=4000) st = await state(page); ob = await page.evaluate(onboard) expect(st['status'].startswith('Watch') and not ob['beckon'], f"the first garden demonstrates itself, and nothing beckons meanwhile ({st['status']!r})") await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=8000) walked = await page.evaluate("window.__samon.path.slice()") want = await page.evaluate("(cells) => cells.map(([r, c]) => window.__samon.key(r, c))", g0['intended']) expect(walked == want, 'the demonstration walks the intended raking, then rakes it') await page.wait_for_function("!window.__samon.demoRunning", timeout=20000) st = await state(page); ob = await page.evaluate(onboard) pill0 = await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(g0['name'])}).className") expect(st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'] and st['seal'] == 'none' and 'done' not in pill0 and 'sealed' not in pill0, f"then the garden is reset for the player, and the demonstration counts for nothing ({st['status']!r}, {pill0!r})") expect(ob['beckon'] and ob['finger'] and ob['anim'] == 1, f'then the waiting monk beckons, and a finger shows the drag {ob}') br = await page.evaluate("[document.getElementById('status').textContent, document.querySelector('.help.puzzle-only').textContent.trim(), document.querySelector('#nav .lock .note').textContent, document.getElementById('rule').firstChild.textContent, document.querySelector('#nav .lock .n').textContent, [...document.querySelectorAll('#actions button, .veranda button')].map(b => b.textContent), document.getElementById('par').parentElement.title]") expect(all(t.startswith('[') and t.endswith(']') for t in br[:3]) and br[3].startswith('[') and not br[4].startswith('[') and not any(t.startswith('[') for t in br[5]) and 'valid raking' in br[6] and f"Par {g0['par']}" in br[6], f"what the game says to the player is set in editorial brackets (status, help, lock notes, a garden's instructions), and rules, counts and controls are not; the counts of rakings live in Par's tooltip ({br[0][:40]!r}, {br[3][:30]!r}, {br[4][:20]!r})") await page.reload(); await page.wait_for_timeout(1500) await quiet(page) st = await state(page); ob = await page.evaluate(onboard) expect(not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and 'Drag the monk in' in st['status'] and ob['beckon'] and ob['finger'], f"a demonstration seen once does not play again by itself ({st['status']!r})") wv = await page.evaluate("!document.getElementById('watch').hidden") await page.click('#watch'); await page.wait_for_timeout(700) running = await page.evaluate("window.__samon.demoRunning") mid = await centers(page, [g0['intended'][1]]); await page.mouse.click(*mid[0]); await page.wait_for_timeout(100) st = await state(page) expect(wv and running and not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'], 'Watch replays the demonstration, and a tap skips it') await page.evaluate("window.__samon.load(2)") expect(await page.evaluate("document.getElementById('watch').hidden"), 'a garden without a demonstration has no Watch button') await page.evaluate("window.__samon.load(0)") far = await centers(page, [[0, len(g0['rows'][0]) - 1]]) await page.mouse.click(*far[0]); st = await state(page) expect('Start at the gate' in st['status'] and st['len'] == 0, 'a press away from the gate says where to start') gp = await page.evaluate("(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); const s = r.width / cv.width; const [x, y] = window.__samon.gate(); return [r.left + x * s, r.top + y * s]; })()") pts = await centers(page, g0['intended'][:3]) await page.mouse.move(*gp); await page.mouse.down() for (x, y) in pts: await page.mouse.move(x, y, steps=3) await page.mouse.up(); st = await state(page); ob = await page.evaluate(onboard) expect(st['len'] == 3 and not ob['beckon'] and not ob['finger'] and ob['started'] == '1', f'dragging the monk in from the gateway starts the walk, and the beckoning stops {ob}') await page.screenshot(path=SHOT + 'shot-phone-light-start.png', full_page=False) await page.evaluate("window.__samon.load(2)"); ob = await page.evaluate(onboard) expect(ob['beckon'] and not ob['finger'], f'after the first walk, fresh gardens still beckon, without the finger {ob}') # 1. every garden at par, by mouse: letting go of a complete walk rakes it for i, g in enumerate(G if not os.environ.get('SAMON_QUICK') else G[:3]): await page.evaluate(f"window.__samon.load({i})") await drag(page, g['intended']) await page.wait_for_timeout(350) st = await state(page) ok = st['len'] == len(g['intended']) and st['turns'] == g['par'] and st['mode'] == 'reveal' pts = await centers(page, [g['intended'][0]]); await page.mouse.click(*pts[0]) # skip the reveal await page.wait_for_timeout(60) st2 = await state(page) expect(ok and st2['mode'] == 'done' and st2['seal'] == 'grid' and 'par' in st2['status'], f"{g['name']}: drawn, raked on letting go, at par, sealed") # 2. refusals: wide rake U-turn; pebbles wi = next(i for i, g in enumerate(G) if g['name'] == 'Wide rake') await page.evaluate(f"window.__samon.load({wi})") await drag(page, [[6, 5], [5, 5], [4, 5], [4, 6], [5, 6]]) st = await state(page) expect('wide rake' in st['status'] and st['len'] == 4, 'wide rake refuses an open-sand U-turn') pi = next(i for i, g in enumerate(G) if g['name'] == "The abbot's pebbles") pg = G[pi]; marks = {tuple(map(int, k.split(','))): v for k, v in pg['marks'].items()} rows = pg['rows']; seq = [tuple(c) for c in pg['intended']] j = next(k for k in range(1, len(seq) - 1) if marks.get(seq[k]) == 'S' and any(0 <= seq[k][0] + a < len(rows) and 0 <= seq[k][1] + bb < len(rows[0]) and rows[seq[k][0] + a][seq[k][1] + bb] == '.' and (seq[k][0] + a, seq[k][1] + bb) not in seq[:k + 1] and (a, bb) != (seq[k][0] - seq[k - 1][0], seq[k][1] - seq[k - 1][1]) and (a, bb) != (seq[k - 1][0] - seq[k][0], seq[k - 1][1] - seq[k][1]) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1)))) v = seq[j]; din = (v[0] - seq[j - 1][0], v[1] - seq[j - 1][1]) turn = next((v[0] + a, v[1] + bb) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1)) if (a, bb) != din and (a, bb) != (-din[0], -din[1]) and 0 <= v[0] + a < len(rows) and 0 <= v[1] + bb < len(rows[0]) and rows[v[0] + a][v[1] + bb] == '.' and (v[0] + a, v[1] + bb) not in seq[:j + 1]) await page.evaluate(f"window.__samon.load({pi})") await drag(page, [list(c) for c in seq[:j + 1]] + [list(turn)]) st = await state(page) expect('pale pebble' in st['status'] and st['len'] == j + 1, 'a pale pebble refuses a turn') # 3. Restore after a tap-rewind and after Start over ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm') await page.evaluate(f"window.__samon.load({ci})") await page.click('#restart') await drag(page, G[ci]['intended'][:12]) pts = await centers(page, [G[ci]['intended'][3]]); await page.mouse.click(*pts[0]) st = await state(page) expect(st['len'] == 4 and st['restore'], 'tapping a footprint rewinds to it, and offers Restore') await page.click('#restore'); st = await state(page) expect(st['len'] == 12 and not st['restore'], 'Restore brings the rewound route back') await page.click('#restart'); st = await state(page) expect(st['len'] == 0 and st['restore'], 'Start over clears the route, and offers Restore') await page.click('#restore'); st = await state(page) expect(st['len'] == 12, 'Restore after Start over') # 3b. after raking: a tap on a cell walks back to it (the sand is smoothed back to the footprints), Restore rakes it again; # holding at the last cell does not rake until the mouse lets go; the keyboard rakes after a beat await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart') full = G[ci]['intended'] pts = await centers(page, full) await page.mouse.move(*pts[0]); await page.mouse.down() for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3) await page.wait_for_timeout(700); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == len(full), 'a complete walk is not raked while the mouse is still down') await page.mouse.up(); await page.wait_for_timeout(350); st = await state(page) expect(st['mode'] == 'reveal', 'letting go starts the raking') await page.mouse.click(*pts[0]); await page.wait_for_timeout(700) k = len(full) - 5 await page.mouse.click(*pts[k - 1]); await page.wait_for_timeout(100); st = await state(page) ref = await page.evaluate("window.__samon.sand()") expect(st['mode'] == 'draw' and st['len'] == k and st['seal'] == 'none' and st['restore'], 'after raking, tapping a cell walks back to it, and offers Restore') await page.evaluate("(w) => window.__samon.setPath(w)", full[:k]); fresh = await page.evaluate("window.__samon.sand()") expect(abs(ref - fresh) < 1e-6, 'walking back smooths the raked sand back to the footprints') await page.evaluate("(w) => window.__samon.setPath(w)", full) await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'a walk completed any other way is raked after a beat') await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(60); st = await state(page) expect(st['mode'] == 'done', 'Enter skips the raking') await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == len(full) - 1, 'Backspace after raking steps back one footprint') await page.click('#restore'); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'Restore brings the whole walk back, and it is raked again') await page.keyboard.press('Enter'); await page.wait_for_timeout(60) # the keyboard: the last arrow rakes after a beat, in which Backspace can still take it back ti = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares') await page.evaluate(f"window.__samon.load({ti})"); await page.focus('#garden') keys = ('ArrowUp', 'ArrowUp', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowUp') # in at the gate, then a serpentine for kk in keys: await page.keyboard.press(kk) await page.keyboard.press('Backspace'); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == 8, 'Backspace in the beat takes the last step back, and nothing is raked') await page.keyboard.press(keys[-1]); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] in ('reveal', 'done') and st['len'] == 9, 'from the keyboard, a complete walk is raked after a beat') await page.keyboard.press('Enter'); await page.wait_for_timeout(60); await page.keyboard.press('Enter'); await page.wait_for_timeout(800) nm = await page.evaluate("window.__samon.G.name") expect(nm == G[ti + 1]['name'], 'Enter after raking goes on to the next garden') # a walk that covers every cell but may not end where it does is not raked wi2, bad = None, None for j, gg in enumerate(G): if not gg['wide'] or gg['end']: continue gx = garden_of(gg, braced=False) for s_ in gx.solve(mode=0, cap=400, stream=True)['sols']: if not check_solution(garden_of(gg), s_['draw']): wi2, bad = j, [list(c) for c in s_['draw']]; break if bad: break await page.evaluate(f"window.__samon.load({wi2})"); await drag(page, bad); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == len(bad) and 'wide rake down' in st['status'], f"a complete walk ending in open sand under the wide rake is not raked ({G[wi2]['name']})") # 4. the abbot's hints await page.evaluate(f"window.__samon.load({ci})") await page.click('#restart'); await page.click('#hint'); st = await state(page) expect(G[ci]['hint'] in st['status'], 'first hint is the authored observation') await page.click('#hint'); st = await state(page) expect('at par' in st['status'] and st['ghosts'] == 1, 'second hint: route can reach par, one groove sketched') found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30); const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", found) await page.click('#hint'); st = await state(page) expect('no longer at par' in st['status'] and any('for par' in a for a in st['actions']), "'no longer at par' with a rewind offer") await page.click('#actions button >> nth=0'); st = await state(page) a = await page.evaluate("window.__samon.analyze(window.__samon.path).state") expect(a == 'par' and st['restore'], 'the par rewind lands on a route that can reach par, and is restorable') fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent.replace(/^\\[|\\]$/g, '')]") n0 = await page.evaluate("window.__samon.path.length"); await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowRight'); n1 = await page.evaluate("window.__samon.path.length") expect(fk[0] == 'garden' and fk[1].startswith('Stepped back') and 'par is still possible' in fk[1] and 'no longer at par' not in fk[1] and n1 > n0, f"after the abbot's rewind the garden has the keyboard again, and the old message has given way to what now holds ({fk[1][:70]!r})") found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 8 + s % 30); const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'stuck' && a.back < w.length - 1 && a.why.cells.length) return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", found) await page.click('#hint'); st = await state(page) expect('to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), "'stuck': reason, marked cells, rewind offer, action first") await page.screenshot(path=SHOT + 'shot-phone-light-stuck.png', full_page=True) await page.click('#actions button >> nth=0') a = await page.evaluate("window.__samon.analyze(window.__samon.path).state") fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent]") expect(a in ('par', 'finish') and fk[0] == 'garden' and 'can be finished from here' in fk[1] and 'marked footprint' not in fk[1], f"the rewind lands on a route that can be finished, says so, and keeps the keyboard ({fk[1][:60]!r})") # 5. assists await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart') await drag(page, G[ci]['intended'][:20] if False else [[6, 3], [5, 3], [5, 2], [5, 1], [5, 0], [4, 0], [3, 0], [3, 1], [4, 1], [4, 2], [4, 3], [4, 4], [3, 4], [3, 5]]) for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid) pressed = await page.evaluate("['aPreview','aChecker','aCut'].map(id => document.getElementById(id).getAttribute('aria-pressed'))") expect(pressed == ['true'] * 3, 'assist toggles switch on') await page.screenshot(path=SHOT + 'shot-phone-light-assists.png', full_page=True) for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid) # 6. practice garden await page.click('text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view s0 = await page.evaluate("window.__samon.practice()") box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()") L, T, W, H = box import math for k, (cx, cy, rad) in enumerate([(0.27, 0.40, 0.18), (0.72, 0.65, 0.14)]): pts = [(L + W * (cx + rad * math.cos(t / 30 * 2 * math.pi) * 0.75), T + H * (cy + rad * math.sin(t / 30 * 2 * math.pi))) for t in range(0, 34)] await page.mouse.move(*pts[0]); await page.mouse.down() for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=4) await page.mouse.up() for yy in (0.1, 0.16, 0.86, 0.92): await page.mouse.move(L + W * 0.05, T + H * yy); await page.mouse.down() await page.mouse.move(L + W * 0.95, T + H * yy, steps=40); await page.mouse.up() s1 = await page.evaluate("window.__samon.practice()") expect(abs(s1['sum'] - s0['sum']) > 50, 'raking changes the practice sand') await page.screenshot(path=SHOT + 'shot-phone-light-practice.png', full_page=True) await page.click('#toolStone'); await page.mouse.click(L + W * 0.5, T + H * 0.2) s2 = await page.evaluate("window.__samon.practice()") expect(s2['stones'] == s0['stones'] + 1, 'Stones tool places a stone') await page.mouse.click(L + W * 0.5, T + H * 0.2) s3 = await page.evaluate("window.__samon.practice()") expect(s3['stones'] == s0['stones'], 'tapping a stone lifts it') await page.click('#smooth'); s4 = await page.evaluate("window.__samon.practice()") expect(abs(s4['sum'] - s0['sum']) < 1e-6, 'Smooth restores the unraked sand') await page.click('#nav [aria-label="Calm"]'); st = await state(page) expect(await page.evaluate("window.__samon.view") == 'puzzle', 'back from the practice garden to a garden') await ctx.close() # 7. dark mode: a finished garden, and the practice garden ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='dark') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page, delay=100, hold=3600000) # the screenshot shows the raked garden with its poem si = next(i for i, g in enumerate(G) if g['name'] == 'Spiral') await page.evaluate(f"window.__samon.load({si})"); await drag(page, G[si]['intended']) await page.wait_for_timeout(7200) await page.screenshot(path=SHOT + 'shot-phone-dark-done.png', full_page=True) await page.evaluate("window.__samon.enterPractice()") box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()") L, T, W, H = box for yy in (0.25, 0.42, 0.59, 0.76): await page.mouse.move(L + W * 0.06, T + H * yy); await page.mouse.down() await page.mouse.move(L + W * 0.94, T + H * (yy + 0.04), steps=40); await page.mouse.up() await page.screenshot(path=SHOT + 'shot-phone-dark-practice.png', full_page=True) await ctx.close() # 7b. the grey seal: help beyond the abbot's first observation marks the raking (fresh profile) ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm') ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares') async def rake_intended(i): await drag(page, G[i]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60) return await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]") async def pill_class(name): return await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(name)}).className") await page.evaluate(f"window.__samon.load({ni})") await page.click('#hint') # the authored observation only: free r1 = await rake_intended(ni) expect(r1[0] == 'grid' and not r1[1] and 'grey' not in r1[2], 'the first observation alone leaves the seal vermilion') await page.evaluate(f"window.__samon.load({ci})") await page.click('#hint'); await page.click('#hint') # observation, then a verdict: help st = await state(page) expect('grey seal' in st['status'], 'the first real help says the seal will be grey') await page.click('#restart') # starting over does not undo help r2 = await rake_intended(ci) expect(r2[0] == 'grid' and r2[1] and 'grey' in r2[2], 'a helped raking at par earns the grey seal, even after Start over') expect('grey' in await pill_class('Calm'), 'the garden list shows a grey seal') await page.click('#restart') r3 = await rake_intended(ci) expect(r3[0] == 'grid' and not r3[1] and 'grey' not in r3[2], 'raking it again unaided earns the vermilion seal') expect('sealed' in await pill_class('Calm'), 'the garden list now shows the vermilion seal') sand0 = await page.evaluate("window.__samon.sand()") await page.click('#restart') sand1 = await page.evaluate("window.__samon.sand()") expect(abs(sand0 - sand1) > 1, 'Start over smooths the raked sand back to unraked') # help travels with the route it helped: stepping back into a helped raking and restoring it re-rakes it grey ti = next(i for i, g in enumerate(G) if g['name'] == 'The pocket') await page.evaluate(f"window.__samon.load({ti})") await page.click('#hint'); await page.click('#hint') r4 = await rake_intended(ti) await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60) h = await page.evaluate("window.__samon.helped") async def rerake(): # Restore rakes again after a beat; Enter (on the garden) skips it await page.click('#restore') await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) if await page.evaluate("window.__samon.mode") == 'reveal': await page.focus('#garden'); await page.keyboard.press('Enter') await page.wait_for_function("window.__samon.mode === 'done'", timeout=3000) await rerake() r5 = await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]") expect(r4[1] and h and r5[0] == 'grid' and r5[1] and 'grey' in r5[2], f'Step back, then Restore, re-rakes a helped raking with the grey seal {r5}') tp = await centers(page, [G[ti]['intended'][2]]); await page.wait_for_timeout(700); await page.mouse.click(*tp[0]) await page.wait_for_function("window.__samon.mode === 'draw'", timeout=3000) await rerake() r6 = await page.evaluate("document.getElementById('seal').classList.contains('grey')") expect(r6, 'so does tapping back into it, then Restore') # an answer the abbot can't give in time costs nothing await page.evaluate(f"window.__samon.load({ci})") found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30); const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", found) h0 = await page.evaluate("window.__samon.helped") await page.evaluate("window.__samon.budget = 1"); await page.click('#hint') st = await state(page); h1 = await page.evaluate("window.__samon.helped") await page.evaluate("window.__samon.budget = 250000") expect(not h0 and "can't be sure" in st['status'] and 'costs you nothing' in st['status'] and not h1, f"an inconclusive answer costs nothing ({st['status']!r})") # a walk that has stopped: why is free, where to step back to is help dead = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 999); if (w.length < window.__samon.G.nSand && w.length > 3) return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", dead); await page.click('#hint') st = await state(page); h2 = await page.evaluate("window.__samon.helped") expect('Saying why a walk has stopped is free' in st['status'] and not h2 and st['actions'] == ['Where can I step back to?'], f"a stopped walk: the abbot says why for free ({st['status']!r}, {st['actions']})") await page.click('#actions button >> nth=0') st = await state(page); h3 = await page.evaluate("window.__samon.helped") expect(h3 and 'to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), 'asking where to step back to is help') await ctx.close() # 7c. worlds open as the world before them is raked (two-thirds of it, at any turn count); the prototype can open all ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) w1 = [i for i, g in enumerate(G) if g['world'] == 1] w1m = [i for i in w1 if not G[i]['extra']]; need = -(-2 * len(w1m) // 3) w1x = len(w1) - len(w1m) nx = await page.evaluate("[document.querySelectorAll('#nav .pip').length, document.querySelectorAll('#nav .pip.extra').length, [...document.querySelectorAll('#nav .fold')].map(b => b.textContent)]") await page.click('#nav .fold'); await page.wait_for_timeout(100) nx2 = await page.evaluate("[document.querySelectorAll('#nav .pip.extra').length, document.querySelectorAll('#nav .more').length, document.querySelector('#nav .fold').textContent]") await page.evaluate(f"window.__samon.load({w1[-1]})"); await page.wait_for_timeout(100) # (an extra of Dawn open: its row unfolds by itself) nx3 = await page.evaluate("[document.querySelectorAll('#nav .pip.extra').length, document.querySelector('#nav .pip[aria-current=\"true\"]').getAttribute('aria-label')]") await page.click('#nav .fold'); await page.evaluate("window.__samon.load(0)"); await page.wait_for_timeout(100) nx4 = await page.evaluate("document.querySelectorAll('#nav .pip.extra').length") expect(nx == [len(w1m), 0, [f'+{w1x} more']] and nx2 == [w1x, 1, 'fewer'] and nx3 == [w1x, G[w1[-1]]['name']] and nx4 == 0, f"a world's extras fold behind '+{w1x} more' (unfolding by themselves while one of them is open), and only open worlds show pips ({nx}, {nx2}, {nx3}, {nx4})") lk = await page.evaluate("[...document.querySelectorAll('#nav .world.locked')].map(r => [r.querySelector('.t').textContent, r.querySelector('.lock .n').textContent, r.querySelector('.lock .note').textContent])") expect(len(lk) == 6 and lk[0][0] == 'Morning' and f'{need} gardens of Dawn' in lk[0][2] and all(n[1] == f"{sum(1 for g in G if g['world'] == k + 2)} gardens ·" for k, n in enumerate(lk)) and all(n[2] == '[Locked.]' for n in lk[1:]), f'at first only Dawn is open; each locked world is one line with its count and what opens it {lk[:2]}') await page.click('#openAll') locked2 = await page.evaluate("[document.querySelectorAll('#nav .world.locked').length, document.querySelectorAll('#nav .pip').length]") await page.click('#openAll') locked3 = await page.evaluate("document.querySelectorAll('#nav .world.locked').length") expect(locked2 == [0, sum(1 for g in G if not g['extra'])] and locked3 == 6, f'the prototype switch opens every world (main paths shown, extras folded), and locks them again {locked2} {locked3}') for k, i in enumerate(w1m[:need]): await page.evaluate(f"window.__samon.load({i})"); await page.click('#restart') await drag(page, G[i]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60) if k == need - 2: still = await page.evaluate("window.__samon.worldOpen(2)") st = await state(page) open2 = await page.evaluate("[window.__samon.worldOpen(2), window.__samon.worldOpen(3), document.querySelectorAll('#nav .world.locked').length]") expect(not still and open2[0] and not open2[1] and 'Morning is open' in st['status'] and open2[2] == 5, f'raking {need} of the {len(w1m)} Dawn gardens on the main path opens Morning, and says so {open2}') # Next follows the main path: from the last Dawn garden on it, past the extras, to the first of Morning; that # garden introduces a rule, and its demonstration plays by itself last1 = w1m[-1] await page.evaluate(f"window.__samon.load({last1})"); await page.click('#restart') await drag(page, G[last1]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[last1]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100) await page.click('#next'); await page.wait_for_timeout(800) nm = await page.evaluate("window.__samon.G.name") first2 = next(g for g in G if g['world'] == 2 and not g['extra']) expect(nm == first2['name'], f'Next from the end of the main path skips the extras ({nm})') if not first2['demo']: i2 = G.index(first2) await drag(page, first2['intended']); await page.wait_for_timeout(350) pts = await centers(page, [first2['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100) await page.click('#next'); await page.wait_for_timeout(800) nm2 = await page.evaluate("window.__samon.G.name") dg = next(g for g in G if g['name'] == nm2) await page.wait_for_timeout(700) expect(dg['demo'] and await page.evaluate("window.__samon.demoRunning"), f'the garden that brings in the ripple stone demonstrates itself ({nm2})') await page.mouse.click(*(await centers(page, [dg['intended'][0]]))[0]); await page.wait_for_timeout(100) # the abbot's observation points at its cells, and they stay marked while the walk goes on pk = next(i for i, g in enumerate(G) if g['name'] == 'The pocket') await page.evaluate(f"window.__samon.load({pk})"); await page.click('#restart'); await page.click('#hint') st = await state(page); ob = await page.evaluate("window.__samon.obs.length") await drag(page, G[pk]['intended'][:3]); ob2 = await page.evaluate("window.__samon.obs.length") expect('He points' in st['status'] and ob == 1 and ob2 == 1, 'the observation points at its cell, which stays marked while drawing') await ctx.close() # 8. desktop: the pebbles garden mid-route ctx = await b.new_context(viewport={'width': 1200, 'height': 1000}, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) await page.evaluate(f"window.__samon.load({pi})"); await drag(page, G[pi]['intended'][:15]) await page.screenshot(path=SHOT + 'shot-desktop-light.png', full_page=True) await ctx.close() # 9. properties, over every garden ctx = await b.new_context(viewport={'width': 390, 'height': 900}, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) bad_bends = [] for i, g in enumerate(G): await page.evaluate(f"window.__samon.load({i})") arcs, turns = await page.evaluate("(c) => window.__samon.strokeBends(c)", g['intended']) if not (arcs == turns == g['par']): bad_bends.append((g['name'], arcs, turns, g['par'])) nside = sum(1 for g in G if g['end']) expect(not bad_bends, f'every garden: the bends drawn in the raked stroke = the turns counted = par ({nside} with a side gate) {bad_bends[:5]}') piv = await page.evaluate("""(n) => { let pairs = 0, bad = 0; for (let i = 0; i < n; i++) { window.__samon.load(i); const G = window.__samon.G; if (!G.wide) continue; for (const v of G.sandList) for (let d = 0; d < 4; d++) { const r = Math.floor(v / G.W) + [-1, 0, 1, 0][d], c = v % G.W + [0, 1, 0, -1][d]; if (r >= 0 && r < G.H && c >= 0 && c < G.W && G.rows[r][c] === 'R') { pairs++; if (G.piv[v * 4 + d]) bad++; } } } return [pairs, bad]; }""", len(G)) expect(piv[0] > 0 and piv[1] == 0, f'under the wide rake, finished sand is never a pivot ({piv[0]} cell sides checked)') wrong, seen = [], 0 quick = bool(os.environ.get('SAMON_QUICK')) # the abbot's explanations: 400 random routes per garden in full, a sample when quick for i, g in enumerate(G): if not (g['end'] or g['wide'] or g.get('marks')): continue res = await page.evaluate(f"""(() => {{ const out = []; for (let s = 1; s < {30 if quick else 400}; s++) {{ const w = window.__samon.randomWalk({i}, s, 999); const a = window.__samon.analyzeFor({i}, w); if (a.state === 'stuck' && a.why && /only one way in, so the walk would have to end there/.test(a.why.text)) out.push([a.why.text, a.why.cells[0]]); }} return out; }})()""") W_ = len(g['rows'][0]); end = g['end'][0] * W_ + g['end'][1] if g['end'] else -1 marks = {int(k.split(',')[0]) * W_ + int(k.split(',')[1]) for k in (g.get('marks') or {})} for text, cell in res: seen += 1 want = 'side gate' if end >= 0 and cell != end else 'pebble' if cell in marks else 'wide rake' if want not in text: wrong.append((g['name'], text)) expect(seen > 0 and not wrong, f'a dead-end cell is explained by the rule that actually fails ({seen} explanations checked) {wrong[:3]}') await ctx.close() # 10. the poem is the reward: after any raking (here off par, and with the abbot's help) a poem is laid over the # garden, and then the next garden fades in; a tap on the poem goes on, a tap beside it stays; 'original' shows the Japanese; Enter goes on at once; a # demonstration earns no poem; the practice garden has one PO = json.load(open('/home/claude/zen/poems/samon-poems.json')) ppoem = lambda name: PO['poems'][PO['gardens'][name]] ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) pc = await page.evaluate("window.__samon.poems()") expect(pc['gardens'] == len(G) and pc['practice'], f'every garden has a poem, and so does the practice garden {pc}') await quiet(page, delay=200, hold=1500, fade=300) ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares') gx = garden_of(G[ni]) sub = next(s_['draw'] for s_ in gx.solve(mode=0, cap=100, stream=True)['sols'] if turns_of(gx, s_['draw']) > G[ni]['par']) await page.evaluate(f"window.__samon.load({ni})") await page.click('#hint'); await page.click('#hint') await drag(page, [list(c) for c in sub]); await page.wait_for_timeout(350) pts = await centers(page, [list(sub[0])]); await page.mouse.click(*pts[0]) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(400) po = await page.evaluate("window.__samon.poem") expect(po['visible'] and po['verse'] == ppoem('Nine squares')['translation'], f"a raking off par, made with help, still earns its poem ({po['verse'][:40]!r})") nxt = G[ni + 1]['name'] await page.wait_for_function("window.__samon.poem.leaving", timeout=4000) await page.wait_for_function(f"window.__samon.G.name === {json.dumps(nxt)}", timeout=3000) st = await state(page); po = await page.evaluate("window.__samon.poem") expect(st['mode'] == 'draw' and po['state'] == 'none' and not po['visible'], f'then garden and poem fade, and the next garden fades in ({nxt})') i2 = ni + 1 await quiet(page, delay=200, hold=1200, fade=300) await drag(page, G[i2]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[i2]['intended'][0]]); await page.mouse.click(*pts[0]) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(300) beside = await page.evaluate("(() => { const r = document.getElementById('poem').getBoundingClientRect(), c = document.querySelector('#poem .card').getBoundingClientRect(); return [r.left + 3, c.top > r.top + 6 ? r.top + 3 : r.top + r.height - 3]; })()") await page.mouse.click(*beside); await page.wait_for_timeout(2000) st = await state(page); po = await page.evaluate("window.__samon.poem"); nm = await page.evaluate("window.__samon.G.name") expect(nm == G[i2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Staying' in st['status'] and 'Read the poem again' in st['actions'], f"a tap beside the poem puts it away and stays with the garden ({st['status'][-60:]!r})") await page.click('#actions button:has-text("Read the poem again")'); await page.wait_for_timeout(1800) po = await page.evaluate("window.__samon.poem") expect(po['visible'], 'the poem can be read again') await page.click('#orig'); await page.wait_for_timeout(100) po = await page.evaluate("window.__samon.poem") expect(po['ja'] == ppoem(G[i2]['name'])['japanese'] and not po['clash'], "'original' shows the Japanese (and no line runs under the seal)") await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(800) nm = await page.evaluate("window.__samon.G.name") expect(nm == G[i2 + 1]['name'], 'Enter goes on to the next garden at once') await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 1]['intended']) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(200) card = await page.evaluate("(() => { const c = document.querySelector('#poem .card').getBoundingClientRect(); return [c.left + c.width / 2, c.top + c.height / 2]; })()") await page.mouse.click(*card); await page.wait_for_timeout(900) nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem") expect(nm == G[i2 + 2]['name'] and po['state'] == 'none', f'a tap on the poem itself goes on to the next garden at once ({nm!r})') await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 2]['intended']) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(700) # (a tap within 0.6 s of the raking is taken as a skip of it) wall = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + 4, r.top + 4]; })()") await page.mouse.click(*wall); await page.wait_for_timeout(300) nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem"); st = await state(page) expect(nm == G[i2 + 2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Read the poem again' in st['actions'], f'a tap on the walls around the poem puts it away and stays ({nm!r}, {po["state"]})') fit = await page.evaluate(f"""(() => {{ const out = [], fs = [], moved = [0, 0]; for (let i = 0; i < {len(G)}; i++) for (const o of [false, true]) {{ const r = window.__samon.poemFit(i, o); fs.push(r.fs); moved[0] += r.left; moved[1] += r.under; if (r.over || (r.clash && !r.under) || (r.under && !o)) out.push([i, o, r]); }} return [out, Math.min(...fs), moved]; }})()""") expect(not fit[0], f'on a phone, every poem fits its garden and clears the seal (set flush left {fit[2][0]} times, over a faded seal {fit[2][1]} times, ' f'only with the original; smallest type {fit[1]:.1f}px) {fit[0][:3]}') await page.evaluate("window.__samon.load(0)"); await page.evaluate("window.__samon.startDemo()") await page.wait_for_function("document.getElementById('status').textContent.startsWith('[That is')", timeout=15000) po = await page.evaluate("window.__samon.poem") expect(po['state'] == 'none' and not po['visible'], 'a demonstration earns no poem') await page.evaluate("window.__samon.enterPractice()") pp = await page.evaluate("document.getElementById('ppoem').textContent") expect(PO['poems'][PO['practice']]['translation'].split('\n')[0] in pp, 'the practice garden has its poem') await ctx.close() # 11. the scroll of poems earned, and the daily garden (one for everyone, picked by the date; raked once, by Rake) DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json')) ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page, delay=200, hold=3600000) await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150) sc0 = await page.evaluate("[document.querySelectorAll('#scroll .entry').length, getComputedStyle(document.getElementById('scroll')).display !== 'none', getComputedStyle(document.getElementById('stage')).display, window.__samon.view]") expect(sc0 == [1, True, 'none', 'poems'], f'the scroll before any raking: only the practice poem, shown in place of the garden {sc0}') for i in (0, 1): await page.evaluate(f"window.__samon.load({i})") await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter') await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150) sc = await page.evaluate("[[...document.querySelectorAll('#scroll .entry .verse')].map(e => e.textContent), [...document.querySelectorAll('#scroll .entry .ja')].map(e => e.textContent), [...document.querySelectorAll('#scroll .left')].map(e => e.textContent)]") expect(sc[0][:2] == [ppoem(G[0]['name'])['translation'], ppoem(G[1]['name'])['translation']] and sc[1][:2] == [ppoem(G[0]['name'])['japanese'], ppoem(G[1]['name'])['japanese']] and len(sc[0]) == 3, f'two gardens raked: their poems, in order, with the Japanese, then the practice poem ({len(sc[0])})') expect(sc[2][0].startswith('[2 of') and sc[2][1] == f"[{sum(1 for g in G if g['world'] == 1) - 2} more in the gardens of Dawn.]", f'the scroll counts what is earned and what is left, in editorial brackets {sc[2][:2]}') pills = await page.evaluate("[...document.querySelectorAll('#nav .pill')].map(b => b.textContent)") sw = await page.evaluate("[document.getElementById('soundToggle').textContent, document.getElementById('soundPanel').hidden]") expect(any(t.startswith('Poems · 2 of') for t in pills) and pills[0].startswith("Today's garden") and sw[0] in ('Sound on', 'Sound off') and sw[1], f'the nav offers the poems and the daily garden; the sound switch waits under the speaker {pills} {sw}') await page.click('#scroll .entry .by button >> nth=1'); await page.wait_for_timeout(150) v = await page.evaluate("[window.__samon.view, window.__samon.G.name]") expect(v == ['puzzle', G[1]['name']], f"a garden's name in the scroll opens it {v}") expect(len(DAILY) == 366 and all(e['world'] == 0 and e['intended'] and e['par_moves'] for e in DAILY), f'a year of daily gardens is embedded ({len(DAILY)})') await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") d = await page.evaluate("window.__samon.daily") expect(d['year'] == len(DAILY) and d['key'] == '2026-10-04' and d['dayNo'] == 276 and not d['done'], f'the daily slot: day 276 of the year, not yet raked {d}') await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(250) g = await page.evaluate("window.__samon.G") day = DAILY[276 % len(DAILY)] hdr = await page.evaluate("[document.getElementById('time').textContent, document.getElementById('gname').textContent, document.getElementById('next').textContent, document.getElementById('rakeDay').hidden]") expect(g['rows'] == day['rows'] and hdr == ['Daily garden', '4 October 2026', 'Back to the gardens', True], f'today\'s garden is the year\'s day 276, under its date {hdr}') await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(900) st = await page.evaluate("[window.__samon.mode, document.getElementById('rakeDay').hidden, document.getElementById('status').textContent]") expect(st[0] == 'draw' and not st[1] and 'press Rake' in st[2], f'a complete walk is not raked by itself here: it waits for Rake {st}') await page.click('#rakeDay') await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000) await page.wait_for_function("window.__samon.poem.visible", timeout=5000) d2 = await page.evaluate("window.__samon.daily") st2 = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.poem.state, document.getElementById('seal').hidden]") expect(d2['done'] and d2['streak'] == 1 and d2['turns'] == day['par'] and d2['kind'] == 'red' and 'Streak: 1 day' in st2[0] and st2[1] == ["Share today's score", 'Read the poem again'] and st2[2] == 'stay' and not st2[3], f'raked at par: the record, a streak of one, the seal, the share action; the poem stays {d2} {st2}') box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()") await page.mouse.click(*box); await page.wait_for_timeout(250); await page.mouse.click(*box); await page.wait_for_timeout(250) st3 = await page.evaluate("[window.__samon.mode, document.getElementById('undo').disabled, document.getElementById('restart').disabled]") expect(st3 == ['done', True, True], f'the raking is final: no walking back, Step back and Start over off {st3}') await page.reload(); await page.wait_for_timeout(400) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300) st4 = await page.evaluate("[window.__samon.mode, document.getElementById('status').textContent, document.getElementById('seal').hidden, window.__samon.daily.streak, window.__samon.path.length]") expect(st4[0] == 'done' and "Today's record" in st4[1] and not st4[2] and st4[3] == 1 and st4[4] == len(day['intended']), f'after a reload the day\'s raking and its record are back {st4[:4]}') await page.evaluate("window.__samon.today = '2026-10-05T12:00:00'") await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300) d3 = await page.evaluate("window.__samon.daily"); g3 = await page.evaluate("window.__samon.G.rows") expect(d3['key'] == '2026-10-05' and not d3['done'] and d3['streak'] == 1 and g3 == DAILY[277 % len(DAILY)]['rows'] and await page.evaluate("window.__samon.mode") == 'draw', f'the next day is a fresh garden, and the streak counts yesterday {d3}') nx = await page.evaluate("(() => { window.__samon.load(0); document.getElementById('next').click(); return window.__samon.G.name; })()") expect(nx != d3['name'] and nx == G[1]['name'], f'the daily garden is never the next garden ({nx})') await ctx.close() # 12. drag intent: the walk follows the axis the hand moves on, not the cell under the pointer; the grip survives the walls ctx = await b.new_context(viewport={'width': 700, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200); await quiet(page) gi = next(i for i, g in enumerate(G) if len(g['rows']) >= 6 and len(g['rows'][0]) >= 7 and g['gate'][2] == 'S' and g['gate'][0] == len(g['rows']) - 1 and set(g['rows'][-1] + g['rows'][-2]) == {'.'} and g['rows'][-3][g['gate'][1] + 3] == '.') H, W = len(G[gi]['rows']), len(G[gi]['rows'][0]); gr, gc = G[gi]['gate'][:2] async def at(r, c, dx=0.0, dy=0.0): return await page.evaluate("""([r, c, dx, dy]) => { const cv = document.getElementById('garden'); const b = cv.getBoundingClientRect(); const s = b.width / cv.width; const g = window.__samon.geom(); return [b.left + (g.ox + (c + 0.5 + dx) * g.cs) * s, b.top + (g.oy + (r + 0.5 + dy) * g.cs) * s]; }""", [r, c, dx, dy]) async def walk(): return await page.evaluate("window.__samon.path.map(k => [Math.floor(k / window.__samon.G.W), k % window.__samon.G.W])") async def begin(): await page.evaluate(f"window.__samon.load({gi}); window.__samon.setPath([]); document.getElementById('garden').scrollIntoView({{block: 'center'}})"); await page.wait_for_timeout(50) x, y = await at(gr, gc); await page.mouse.move(x, y); await page.mouse.down() run = [[gr, c] for c in range(gc, gc + 4)] await begin(); x, y = await at(gr, gc + 3, 0, -0.9); await page.mouse.move(x, y, steps=40); await page.mouse.up() expect(await walk() == run, f'a drag along the row whose hand drifts 0.9 of a cell into the next row still draws the row ({await walk()})') await begin(); x, y = await at(gr, gc + 1, 0, 1.6); await page.mouse.move(x, y, steps=12); x, y = await at(gr, gc + 3, 0, 1.8); await page.mouse.move(x, y, steps=30); await page.mouse.up() expect(await walk() == run, f'a pointer below the wall keeps its grip and the walk follows along the row ({await walk()})') await begin(); x, y = await at(gr, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=40) for k in range(1, 13): # a U-turn: a semicircle within the last cell of the run, up into the next row a = math.pi / 2 - math.pi * k / 12 x, y = await at(gr, gc + 3, 0.1 + 0.35 * math.cos(a), -0.35 + 0.35 * math.sin(a)); await page.mouse.move(x, y) x, y = await at(gr - 1, gc); await page.mouse.move(x, y, steps=40); await page.mouse.up() expect(await walk() == run + [[gr - 1, c] for c in range(gc + 3, gc - 1, -1)], f'a U-turn drawn as an arc turns once and comes back along the next row ({await walk()})') await begin(); x, y = await at(gr, gc + 3, -0.2, 0.05); await page.mouse.move(x, y, steps=30) for k in range(1, 9): # a rounded corner from heading east to heading north a = (math.pi / 2) * k / 8 x, y = await at(gr, gc + 3, -0.2 + 0.3 * math.sin(a), 0.05 - 0.3 + 0.3 * math.cos(a)); await page.mouse.move(x, y) x, y = await at(gr - 2, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=30); await page.mouse.up() expect(await walk() == run + [[gr - 1, gc + 3], [gr - 2, gc + 3]], f'a rounded corner is a run and a turn ({await walk()})') await begin(); x, y = await at(gr, gc + 3, 0, -0.6); await page.mouse.move(x, y, steps=30); out = await walk() x, y = await at(gr, gc, -0.2, -0.6); await page.mouse.move(x, y, steps=30); await page.mouse.up() expect(out == run and await walk() == [[gr, gc]], f'dragging back along a drifted run retraces it ({out} → {await walk()})') await begin() for k in range(40): x, y = await at(gr, gc, 0.12 * math.cos(k), 0.12 * math.sin(k)); await page.mouse.move(x, y) await page.mouse.up() expect(await walk() == [[gr, gc]], f'a wobble in place takes no step ({await walk()})') await ctx.close() # 13. sound: nothing before a gesture; the synthesized sounds render; Sound off persists; the recordings carried in the # page are found, credited on the scroll with their source, put down to follow a world's bell, and play import struct PIECES = json.load(open('/home/claude/zen/out/sound_pieces.json')) ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) s0 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.tracks]") expect(s0 == [True, False, 'none', None], f'before any gesture there is no audio context, and the recordings are not looked at {s0}') await page.click('#garden'); await page.wait_for_timeout(200) s1 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.timers, window.__samon.sound.tracks.map(t => [t.title, t.src.slice(0, 22), t.dur])]") expect(s1[:2] == [True, 'running'] and s1[2] in (6, 7) and s1[3] == [[p['title'], 'data:audio/mpeg;base64', p['dur']] for p in PIECES], f'a touch starts the audio, the six ambience loops (and, for a moment, the speaker\'s redraw), and finds the recordings in the page {s1}') wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'drip', 0.3], [0.3, 'tock', 0.4], [0.7, 'tok', 0.4], [0.9, 'bell', 0.5]], 1.3)")) pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:]) def peak(t0, t1): return max(abs(v) for v in pcm[int(t0 * 44100):int(t1 * 44100)]) / 32768 pk = [round(peak(*w), 3) for w in ((0.05, 0.15), (0.3, 0.4), (0.7, 0.75), (0.9, 1.2))] expect(all(p > 0.03 for p in pk) and peak(0, 0.05) == 0, f'drip, pipe, seal and bell each render audibly, and nothing before the first {pk}') wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'scrape', 1.2, 1], [1.5, 'crunch', 0.14], [1.8, 'birds', 0.1], [3.0, 'crickets', 0.08, 0.5], [3.7, 'wind', 0.3], [3.8, 'gust', 0.4, 1]], 5)")) pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:]) pk = [round(peak(*w), 3) for w in ((0.3, 1.1), (1.5, 1.6), (1.85, 2.9), (3.0, 3.5), (4.3, 5.0))] expect(all(p > 0.015 for p in pk) and peak(1.44, 1.5) < 0.02, f'the rake\'s scrape, a footstep, a bird, a cricket and the wind each render audibly, and the scrape dies away when the rake stops {pk} (tail {peak(1.44, 1.5):.3f})') dp = await page.evaluate("[5, 6, 12, 18, 19, 20, 23, 4].map(h => window.__samon.sound.dayPart(h))") expect(dp == ['dusk', 'day', 'day', 'day', 'dusk', 'night', 'night', 'night'], f'birds from 6 to 19, crickets from 20 to 5, by the local clock {dp}') await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended']) await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=3000) sc1 = await page.evaluate("window.__samon.sound.scraping") await page.wait_for_function("window.__samon.mode === 'done'", timeout=12000); await page.wait_for_timeout(100) sc2 = await page.evaluate("window.__samon.sound.scraping") expect(sc1 and not sc2, f'the scrape runs with the raking and stops with it ({sc1}, {sc2})') await page.evaluate("window.__samon.sound.setVolume(0.3)"); await page.wait_for_timeout(300) vs = await page.evaluate("[window.__samon.sound.vol, Math.round(window.__samon.sound.master * 100) / 100, localStorage.getItem('samon-sound-vol'), document.getElementById('soundVol') && document.getElementById('soundVol').value, !!document.getElementById('hapticsToggle'), window.__samon.sound.haptics]") expect(vs[:3] == [0.3, 0.3, '0.3'] and vs[4] and vs[5], f'the volume is set, remembered and reaches the master gain; the haptics switch shows where the device can tick {vs}') pn0 = await page.evaluate("document.getElementById('soundPanel').hidden") await page.click('#soundMore'); await page.wait_for_timeout(100) pn1 = await page.evaluate("[document.getElementById('soundPanel').hidden, document.getElementById('soundMore').getAttribute('aria-expanded'), document.getElementById('volRow').hidden]") await page.click('#hapticsToggle'); await page.wait_for_timeout(100) hp = await page.evaluate("[window.__samon.sound.haptics, localStorage.getItem('samon-haptics'), document.getElementById('hapticsToggle').textContent]") expect(pn0 and pn1 == [False, 'true', False] and hp == [False, '0', 'Haptics off'], f'the chevron by the speaker opens the sound panel (switch, volume, haptics); haptics can be turned off there, and that is remembered {pn1} {hp}') await page.mouse.click(5, 400); await page.wait_for_timeout(100) expect(await page.evaluate("document.getElementById('soundPanel').hidden"), 'a press elsewhere closes the panel') OPEN_PANEL = "(() => { if (document.getElementById('soundPanel').hidden) document.getElementById('soundMore').click(); })()" s2 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.on]") expect(s2 == [True, 'running', True], f'the live context is untouched by the offline render {s2}') tm = await page.evaluate("(() => { const a = window.__samon.sound.timers; window.__samon.sound.piece('capstone'); return [a, window.__samon.sound.timers]; })()") expect(tm[1] == tm[0] + 1, f'after a world\'s bell a piece is put down to follow {tm}') await page.evaluate(f"window.__samon.sound.play(0, {PIECES[0]['dur'] - 12})"); await page.wait_for_function("window.__samon.sound.playing !== null", timeout=5000) await page.wait_for_timeout(4000); lv = await page.evaluate("window.__samon.sound.level()") expect(lv > 0.002, f'the recording is heard: the output meter reads {lv:.4f} while it plays (silence would mean the media source is muted)') await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150) cr = await page.evaluate("[document.getElementById('soundCredits').textContent, [...document.querySelectorAll('#soundCredits a')].map(a => [a.textContent, a.href])]") expect(f"Shakuhachi, now and then: {PIECES[0]['title']}, {PIECES[0]['player']} ({PIECES[0]['license']})." in cr[0] and cr[1] == [[PIECES[0]['title'], PIECES[0]['source']]], f'the scroll credits the recording, with its source {cr}') await page.wait_for_function("window.__samon.sound.playing === null", timeout=20000) expect(True, 'the piece plays to its end and is let go') await page.evaluate(OPEN_PANEL); await page.click('#soundToggle'); await page.wait_for_timeout(150) s3 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]") expect(s3 == [False, 'suspended', 0, 'Sound off', '0'], f'Sound off suspends the context, clears the loops, and is remembered {s3}') await page.reload(); await page.wait_for_timeout(300); await quiet(page); await page.click('#garden'); await page.wait_for_timeout(200) s4 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, document.getElementById('soundToggle').textContent]") expect(s4 == [False, False, 'Sound off'], f'after a reload sound stays off, and a touch makes no context {s4}') noslider = await page.evaluate("document.getElementById('volRow').hidden") await page.evaluate(OPEN_PANEL); await page.click('#soundToggle'); await page.wait_for_timeout(200) s5 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundVol').value, window.__samon.sound.vol]") expect(s5[:2] == [True, 'running'] and s5[2] in (6, 7) and s5[3:] == ['30', 0.3] and noslider, f'Sound on starts it again, with its loops (drip, pipe, wind, gusts, birds, crickets) and the remembered volume; no slider while off {s5}') # the mute button and the M key; what the page can tell of the browser's own silence mb = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), b.title]; })()") await page.click('#mute'); await page.wait_for_timeout(120) mb2 = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), window.__samon.sound.on, window.__samon.sound.state, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]; })()") await page.keyboard.press('m'); await page.wait_for_timeout(200) mb3 = await page.evaluate("[document.getElementById('mute').className, window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers > 0]") expect(mb == ['mute', 'false', 'Mute', 'Mute (M)'] and mb2 == ['mute off', 'true', 'Unmute', False, 'suspended', 'Sound off', '0'] and mb3 == ['mute', True, 'running', True], f'the speaker in the header mutes and unmutes, as does M, in step with the list\'s switch and the saved setting {mb} {mb2} {mb3}') bl = await page.evaluate("(() => { navigator.getAutoplayPolicy = () => 'disallowed'; window.__samon.sound.renderMute(); const b = document.getElementById('mute'); const r = [window.__samon.sound.blocked, b.className, b.getAttribute('aria-label')]; delete navigator.getAutoplayPolicy; window.__samon.sound.renderMute(); return r.concat([b.className]); })()") expect(bl == [True, 'mute blocked', 'Sound is blocked by the browser', 'mute'], f'when the browser says it will not play (Firefox\'s autoplay policy), the speaker says so in red, and recovers {bl}') aud = await page.evaluate("[typeof navigator.audioSession, typeof navigator.mediaSession, !!document.getElementById('muteHint') || localStorage.getItem('samon-sound-hint') === '1']") expect(aud[2], f'the first start of sound shows a one-time word beside the speaker (audioSession: {aud[0]}, mediaSession: {aud[1]})') await ctx.close() # 14. sharing: a raking is a code in the URL's hash (the walk as a number in the mixed radix of its legal moves), shown # but not credited to whoever opens it; a player's whole progress is another code, offered for taking over; the share # text draws the raking in box-drawing characters ctx = await b.new_context(viewport={'width': 500, 'height': 900}, device_scale_factor=2, color_scheme='light', permissions=['clipboard-read', 'clipboard-write']) page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) lens = []; bits = []; bad = [] for i in (range(len(G)) if not os.environ.get('SAMON_QUICK') else range(0, len(G), 7)): g = G[i]; await page.evaluate(f"window.__samon.load({i})") code = await page.evaluate("(w) => window.__samon.share.code(w)", g['intended']) w = await page.evaluate("(w) => window.__samon.share.walk(w)", g['intended']) back = await page.evaluate("(a) => window.__samon.share.unwalk(a[0], a[1])", [w[0], len(g['intended']) - 1]) h = await page.evaluate("(c) => { const h = window.__samon.share.read(c); return [h.i, h.grey, h.n.toString()]; }", code) if not (code and back == g['intended'] and h == [i, False, w[0]]): bad.append(g['name']) lens.append(len(code)); bits.append(w[1]) expect(not bad and max(lens) <= 18, f'every intended raking round-trips through its code: {len(lens)} gardens, {min(lens)}–{max(lens)} characters (mean {sum(lens) / len(lens):.1f}); the walks take {sum(bits) / len(bits):.1f} bits on average, against {sum(2 * (len(g["intended"]) - 1) for g in G) / len(G):.0f} at two bits a step {bad}') lay = await page.evaluate("window.__samon.share.layouts()") allh = lay['campaign'] + lay['daily'] expect(len(set(allh)) == len(allh) == len(G) + 366 and all(0 <= h < 2 ** 32 for h in allh), f'every garden, campaign or daily, has its own 32-bit layout hash ({len(set(allh))} of {len(allh)} distinct)') legal = await page.evaluate("(() => { window.__samon.load(0); return window.__samon.share.walk([[2, 0], [2, 1], [1, 1]]); })()") expect(legal is None, f'a walk that breaks a rule (onto a stone) has no code {legal}') grid = await page.evaluate("(w) => window.__samon.share.text(w, 'red')", G[0]['intended']) rows = grid.split('\n') expect(rows[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and len(rows) == len(G[0]['rows']) + 3 and all(len(r) == len(G[0]['rows'][0]) + 2 for r in rows[1:]) and rows[1] == '┏━━━━┓' and rows[2] == '┃╶──┐┃' and rows[3] == '┃███│┃' and rows[4] == '┃┌──┘┃' and rows[-1] == '┗┷━━━┛', f'the share text: a heading, then the garden in box-drawing characters, the stroke in light lines through the gate in the heavy wall, stones as blocks\n{grid}') i2 = next(i for i, g in enumerate(G) if g['end'] and g['end_side'] == 'E') await page.evaluate(f"window.__samon.load({i2})") grid2 = await page.evaluate("(w) => window.__samon.share.grid(w)", G[i2]['intended']) r2 = grid2.split('\n') expect(r2[G[i2]['end'][0] + 1].endswith('┨') and sum(1 for ch in grid2 if ch in '┨┠┯┷') == 2, f'a side gate is a second gap in the wall, where the stroke begins\n{grid2}') # #r=: the raking is shown, with its seal, but recorded for nobody; Rake it yourself (or any tap) gives the viewer the garden fresh gi = 5; g5 = G[gi] await page.evaluate(f"window.__samon.load({gi})") code = await page.evaluate("(w) => window.__samon.share.code(w)", g5['intended']) await page.evaluate("localStorage.clear()") await page.goto('about:blank'); await page.goto(URL + '#r=' + code); await page.wait_for_timeout(600); await quiet(page) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, window.__samon.turnsOf(), document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), document.getElementById('seal').hidden, document.getElementById('next').hidden, localStorage.getItem('samon-progress'), !!window.__samon.share.shared, document.getElementById('undo').disabled]") expect(st[:4] == ['done', g5['name'], len(g5['intended']), g5['par']] and st[4] == f"[A raking shared with you: {g5['par']} turns, par {g5['par']}, the abbot's seal. Rake it yourself from the gate: a tap smooths the sand.]" and st[5] == ['Rake it yourself', 'Share it on'] and not st[6] and st[7] and st[8] is None and st[9] and not st[10], f'a link to a raking opens that garden raked, with the seal, credited to nobody {st[:8]} {st[8:]}') pts = await centers(page, [g5['intended'][3]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(300) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('status').textContent, location.hash, !!window.__samon.share.shared, document.getElementById('restore').hidden, document.getElementById('seal').hidden]") expect(st == ['draw', g5['name'], 0, '[Now you: drag the monk in from the gate.]', '', False, True, True], f'a tap on the shared raking smooths it away: the garden is the viewer\'s, from the gate, with nothing to Restore, and the hash is gone {st}') await page.goto(URL + '#r=' + code); await page.wait_for_timeout(400) # (a hash change on the open page shows it too) st = await page.evaluate("[window.__samon.mode, !!window.__samon.share.shared]") await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300) st2 = await page.evaluate("[window.__samon.mode, window.__samon.path.length, !!window.__samon.share.shared, location.hash]") expect(st == ['done', True] and st2 == ['draw', 0, False, ''], f'the same by Rake it yourself, after the link is followed on the open page {st} {st2}') gcode = await page.evaluate("(w) => window.__samon.share.code(w, true)", g5['intended']) await page.goto(URL + '#r=' + gcode); await page.wait_for_timeout(400) st = await page.evaluate("[document.getElementById('seal').className, document.getElementById('status').textContent.includes('his seal in grey')]") expect(st == ['seal grey', True], f'a raking made with the abbot\'s help shows his grey seal {st}') # a daily garden's raking: of another day, the viewer is sent on to today's; of today, raked at par, it is shown and then today's is theirs to rake DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json')) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200) day = DAILY[276 % len(DAILY)] dcode = await page.evaluate("(w) => window.__samon.share.code(w)", day['intended']) await page.goto('about:blank'); await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(600); await quiet(page) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('gname').textContent, document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.daily.key]") expect(st[:3] == ['done', 'daily:2026-10-04', '4 October 2026'] and 'of the daily garden of 4 October 2026' in st[3] and "Today's garden is another" in st[3] and st[4][0] == "Today's garden" and st[5] == '2026-10-04', f'a shared raking of an earlier day\'s garden is shown under its date, and offers today\'s instead {st}') await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, location.hash, window.__samon.daily.done]") expect(st[0] == 'draw' and st[1].startswith('daily:') and st[1] != 'daily:2026-10-04' and st[2] == 0 and st[3] == '' and not st[4], f'Today\'s garden leaves it for today\'s, unraked {st}') await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(400) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('status').textContent.includes('Rake it yourself'), [...document.querySelectorAll('#actions button')].map(b => b.textContent)[0]]") await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300) st2 = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('rakeDay').hidden]") expect(st == ['done', 'daily:2026-10-04', True, 'Rake it yourself'] and st2 == ['draw', 'daily:2026-10-04', 0, True], f'the same day\'s raking, shared, is shown and then left for the viewer to rake {st} {st2}') # the share text and link: the share action after a raking, to the clipboard where there is no share sheet await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) acts = await page.evaluate("[...document.querySelectorAll('#actions button')].map(b => b.textContent)") await page.click('#actions button:has-text("Share this raking")'); await page.wait_for_timeout(400) clip = await page.evaluate("navigator.clipboard.readText()") st = await page.evaluate("document.getElementById('status').textContent") cl = clip.split('\n') expect(acts == ['Share this raking'] and cl[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and cl[1:-1] == grid.split('\n')[1:] and cl[-1].startswith(URL + '#r=') and st.startswith('[Copied: the raking drawn in lines, with a link that opens it'), f'after a raking, Share this raking copies the heading, the drawing and a link that opens the raking {cl[0]!r} {cl[-1][-30:]!r} {st!r}') await page.goto('about:blank'); await page.goto(cl[-1]); await page.wait_for_timeout(600) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, !!window.__samon.share.shared, window.__samon.turnsOf()]") expect(st == ['done', G[0]['name'], True, G[0]['par']], f'and that link shows it {st}') # #p=: progress as a code; the link offers it, Take it over joins it with what is here (whichever is better stands) await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) for i in (0, 1, 2): await page.evaluate(f"window.__samon.load({i})"); await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200) await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(300); await page.click('#rakeDay') await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000) await page.evaluate("window.__samon.load(3)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[3]['intended'][:4]); await page.wait_for_timeout(100) await page.evaluate("window.__samon.askAbbot()"); await page.wait_for_timeout(500) # (help on the unfinished walk travels with it; the save is debounced) pcode = await page.evaluate("window.__samon.share.progress()") mine = json.loads(await page.evaluate("localStorage.getItem('samon-progress')")) expect(len(pcode) < 120 and mine[G[3]['name']].get('helped') and len(mine[G[3]['name']]['draft']) == 4, f'the whole progress is a code of {len(pcode)} characters {pcode}') await page.evaluate("localStorage.clear()"); await page.goto('about:blank') await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) await page.evaluate(f"window.__samon.load(1)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[1]['intended'][:3]); await page.wait_for_timeout(400) # (something of its own here: an unfinished walk on garden 2) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") await page.goto(URL + '#p=' + pcode); await page.wait_for_timeout(500) st = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), localStorage.getItem('samon-progress')]") expect(st[0] == "[This link carries a player's progress: 3 of 148 gardens raked, 3 sealed, 1 daily garden, and an unfinished walk. Take it over here? It joins what is here, and whichever is better stands.]" and st[1] == ['Take it over', 'Leave it'] and G[0]['name'] not in (st[2] or ''), f'a progress link is offered, not taken, with what it holds {st[:2]}') tt = await page.evaluate("[...document.querySelectorAll('.veranda button, .assists button, .assists .t, #nav button, #nav .more, #nav .lock, #actions button, #mute, #soundMore, #soundPanel button, #soundPanel input, #orig, .count span, #where, .brand, #seal, .help summary')].filter(e => !e.title).map(e => e.id || e.className || e.textContent)") expect(not tt, f'every control, count and mark has a tooltip (gwern.net wants one wherever a hover could want a word more) {tt}') await page.click('#actions button >> nth=0'); await page.wait_for_timeout(400) got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')")) st = await page.evaluate("[document.getElementById('status').textContent, location.hash, window.__samon.G.name, window.__samon.path.length, window.__samon.helped, [...document.querySelectorAll('#nav .pip.sealed')].length, window.__samon.daily.done, window.__samon.daily.turns, window.__samon.daily.streak]") expect(all(got[G[i]['name']] == mine[G[i]['name']] for i in (0, 2)) and got[G[1]['name']]['done'] and got[G[1]['name']]['sealed'] and len(got[G[1]['name']]['draft']) == 3 and got[G[3]['name']]['draft'] == mine[G[3]['name']]['draft'] and got[G[3]['name']].get('helped') and got['daily:2026-10-04']['path'] == mine['daily:2026-10-04']['path'] and got['daily:2026-10-04']['turns'] == day['par'] and got['daily:2026-10-04']['kind'] == 'red' and st[0].startswith('[Taken over: 3 of 148 gardens raked') and st[1] == '' and st[2] == G[3]['name'] and st[3] == 4 and st[4] and st[5] == 3 and st[6] and st[7] == day['par'] and st[8] == 1, f'Take it over: the rakings, the seals, the day\'s record with its walk, and the unfinished walk with its help arrive, and the walk of its own here is kept {st}') await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300) st = await page.evaluate("[window.__samon.mode, window.__samon.path.length, document.getElementById('status').textContent.includes(\"Today's record\")]") expect(st == ['done', len(day['intended']), True], f'the carried day\'s raking shows as the record {st}') await page.click('#actions button:has-text("Share today\'s score")'); await page.wait_for_timeout(400) cl = (await page.evaluate("navigator.clipboard.readText()")).split('\n') shape = await page.evaluate("window.__samon.share.grid([])") expect(cl[0] == f"Samon · daily garden 2026-10-04 · {day['par']} turns, par {day['par']} · 静 · streak 1" and cl[1:-1] == shape.split('\n') and cl[-1] == URL + '#daily' and not any(ch in shape for ch in '─│┌┐└┘╴╵╶╷') and shape.count('░') == sum(r.count('.') for r in day['rows']), f'the daily garden shares the score and the garden\'s shape, without the stroke, and a link to today\'s garden {cl[0]!r} {cl[-1][-8:]!r}') await page.evaluate("window.__samon.load(0)"); await page.goto(URL + '#daily'); await page.wait_for_timeout(400) st = await page.evaluate("[window.__samon.G.name, location.hash]") expect(st == ['daily:2026-10-04', ''], f'#daily opens today\'s garden {st}') await page.click('#carry'); await page.wait_for_timeout(400) cl = await page.evaluate("navigator.clipboard.readText()"); st = await page.evaluate("document.getElementById('status').textContent") expect(cl.startswith(URL + '#p=') and st.startswith('[Copied a link that carries your progress'), f'Carry my progress copies such a link {cl[:60]!r} {st!r}') await page.goto('about:blank'); await page.goto(URL + '#r=AAAA'); await page.wait_for_timeout(500) st = await page.evaluate("[document.getElementById('status').textContent, document.getElementById('status').className, location.hash, window.__samon.mode]") expect(st == ["[The link's code can't be read: it was made by another edition of the game.]", 'status warn', '', 'draw'], f'a code the page can\'t read is said so, and dropped {st}') await page.goto(URL + '#p=not a code'); await page.wait_for_timeout(300) st = await page.evaluate("[document.getElementById('status').textContent, location.hash]") expect(st == ["[The link's code can't be read: it is not a Samon code.]", ''], f'so is a string that is no code at all {st}') # durability (Astra's points): a garden is named by a 32-bit layout hash, not its place, so a code survives a reordering of the # campaign and a changed garden is refused, never mistaken for the one meant B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) await page.evaluate("window.__samon.load(5)") code5 = await page.evaluate("(w) => window.__samon.share.code(w)", G[5]['intended']) bits = ''.join(format(B64.index(ch), '06b') for ch in code5) bent = bits[:5] + ('1' if bits[5] == '0' else '0') + bits[6:] # one bit of the layout hash bent = ''.join(B64[int(bent[i:i + 6], 2)] for i in range(0, len(bent), 6)) await page.goto(URL + '#r=' + bent); await page.wait_for_timeout(400) st = await page.evaluate("[document.getElementById('status').textContent, window.__samon.mode, location.hash]") expect(st == ["[The link's code can't be read: its garden is not in this edition, or has changed since.]", 'draw', ''], f'a code whose garden hash matches no garden is refused, not shown over another garden {st}') await page.evaluate("(w) => window.__samon.setPath(w)", G[5]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) pcode5 = await page.evaluate("window.__samon.share.progress()") await page.evaluate("window.__samon.share.swap(5, 9); window.__samon.load(0)") # the campaign reordered under the links await page.goto(URL + '#r=' + code5); await page.wait_for_timeout(400) st = await page.evaluate("[window.__samon.G.name, window.__samon.mode, !!window.__samon.share.shared, window.__samon.idx, document.querySelector('#nav .pip[aria-current=\"true\"]').getAttribute('aria-label')]") expect(st == [G[5]['name'], 'done', True, 9, G[5]['name']], f'after the campaign is reordered, a raking link still opens the garden it was made in, now in its new place {st}') await page.evaluate("localStorage.clear()") await page.goto(URL + '#p=' + pcode5); await page.wait_for_timeout(400) await page.click('#actions button:has-text("Take it over")'); await page.wait_for_timeout(400) got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')")) expect(got.get(G[5]['name'], {}).get('sealed') and G[9]['name'] not in got, f'and carried progress lands on the garden it was earned in, not on the one now at its index {sorted(got)}') await page.evaluate("window.__samon.share.swap(5, 9)") await ctx.close() await b.close() real = [e for e in errors if 'ERR_TUNNEL' not in e and 'Failed to load resource' not in e] expect(not real, f'no page errors {real}') print('FAILS:', fails) asyncio.run(main()) proto/test/smoke.py import asyncio, json from playwright.async_api import async_playwright URL = 'file:///home/claude/zen/proto/test/index.html' async def main(): async with async_playwright() as p: b = await p.chromium.launch() page = await (await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2)).new_page() errs = [] page.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) page.on('pageerror', lambda e: errs.append('PAGEERROR ' + str(e))) await page.goto(URL); await page.wait_for_timeout(300) n = await page.evaluate("GARDENS.length") for i in range(n): r = await page.evaluate(f"(() => {{ window.__samon.load({i}); const a = window.__samon.analyze([window.__samon.G.gateCell]); return [window.__samon.G.name, a.state, window.__samon.G.parK.length]; }})()") print(r) await page.evaluate("window.__samon.enterPractice()") print("practice", await page.evaluate("window.__samon.practice()")) print("errors:", [e for e in errs if 'ERR_TUNNEL' not in e and 'Failed to load resource' not in e]) await b.close() asyncio.run(main()) proto/test/thumbnail.py — The social-media thumbnail (1200×630): a garden raked at par, with its seal, beside the title, in the page's own style. """The social-media thumbnail (1200×630): a garden raked at par, with its seal, beside the title, in the page's own style. Usage: python3 proto/test/thumbnail.py [garden index] -> deliver/2026-10-05-gwern-samon-thumbnail.png (and a preview of the candidates in the scratchpad when run with 'pick'). """ import asyncio, base64, os, sys from playwright.async_api import async_playwright URL = 'file:///home/claude/zen/proto/test/index.html' OUT = '/home/claude/zen/deliver/2026-10-05-gwern-samon-thumbnail.png' PICK = sys.argv[1] if len(sys.argv) > 1 else '25' COMPOSE = """

Samon砂紋

A Zen-garden raking puzzle: walk the monk in from the gate, cover every cell of sand in one stroke, and he rakes his way back out.

gwern.net · design gwern.net · design & playable prototypeamp; prototype

""" async def shot(page, gi, path): await page.evaluate(f"window.__samon.load({gi})"); await page.wait_for_timeout(150) G = await page.evaluate("window.__samon.G") await page.evaluate("(w) => window.__samon.setPath(w)", G['intended']) await page.wait_for_function("window.__samon.mode === 'done'", timeout=20000); await page.wait_for_timeout(500) r = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()") await page.screenshot(path=path, clip={'x': r[0], 'y': r[1], 'width': r[2], 'height': r[3]}) return G['name'] async def main(): async with async_playwright() as p: b = await p.chromium.launch() ctx = await b.new_context(viewport={'width': 560, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page() await page.goto(URL); await page.wait_for_timeout(300) await page.evaluate("localStorage.setItem('samon-open-all', '1'); localStorage.setItem('samon-started', '1'); localStorage.setItem('samon-sound-hint', '1'); localStorage.removeItem('samon-assists')") await page.reload(); await page.wait_for_timeout(400) await page.evaluate("window.__samon.poemTiming = {delay: 3600000}") scratch = '/tmp/claude-0/-home-claude/1d7d7911-7ec3-59eb-91d4-be08299e88f7/scratchpad/' if PICK == 'pick': for gi in (22, 23, 25, 26, 28, 47, 49): name = await shot(page, gi, f'{scratch}thumb-cand-{gi}.png'); print(gi, name) await b.close(); return gi = int(PICK) name = await shot(page, gi, scratch + 'thumb-garden.png') img = base64.b64encode(open(scratch + 'thumb-garden.png', 'rb').read()).decode() comp = await (await b.new_context(viewport={'width': 1200, 'height': 630}, device_scale_factor=1)).new_page() await comp.set_content(COMPOSE.replace('__IMG__', img)); await comp.wait_for_timeout(300) await comp.screenshot(path=OUT, clip={'x': 0, 'y': 0, 'width': 1200, 'height': 630}) print('wrote', OUT, 'from', name) await b.close() asyncio.run(main()) proto/test/wrap.py — Test page = the standalone build, with no extra base styles (so the test can't mask a missing rule in the page). """Test page = the standalone build, with no extra base styles (so the test can't mask a missing rule in the page).""" import shutil shutil.copyfile('/home/claude/zen/proto/samon.standalone.html', '/home/claude/zen/proto/test/index.html') This single-file edition and its test (standalone/ ) standalone/build_standalone.py — Build one fully self-contained HTML file: the Samon design document (from samon.md, via Pandoc) with the playable """Build one fully self-contained HTML file: the Samon design document (from samon.md, via Pandoc) with the playable prototype embedded in the same page, and an appendix with every file of the code bundle (code shown inline; everything in an embedded .zip). No external resources: figures are embedded SVG data URIs, no web fonts, no scripts or styles from elsewhere; only ordinary hyperlinks point outside. Usage: python3 build_standalone.py -> /home/claude/zen/standalone/2026-10-03-gwern-samon.html """ import ast import base64 import hashlib import html import io import json import os import re import subprocess import sys import zipfile from bs4 import BeautifulSoup ROOT = "/home/claude/zen" OUT = f"{ROOT}/standalone/2026-10-03-gwern-samon.html" FIG = f"{ROOT}/fig/final" def must_replace(s, old, new, count=1): n = s.count(old) if n != count: sys.exit(f"expected {count} occurrence(s) of {old[:70]!r}, found {n}") return s.replace(old, new) # ---------------------------------------------------------------- 1. the document: Gwerndown -> Pandoc Markdown md = open(f"{ROOT}/samon.md").read() m = re.match(r"^---\n(.*?)\n\.\.\.\n", md, re.S) if not m: sys.exit("no YAML header") meta = {} for line in m.group(1).splitlines(): k, _, v = line.partition(":") meta[k.strip()] = v.strip().strip('"') body = md[m.end():] body = re.sub(r"\n?", "", body, flags=re.S) # handoff notes and TODOs are for the source, not the page def wiki(mo): # gwern.net's [text](!W) and [text](!W "Article") text, title = mo.group(1), mo.group(2) article = title or re.sub(r"[_*]", "", text) url = "https://en.wikipedia.org/wiki/" + article.replace(" ", "_") return f'[{text}]({url} "{article} (Wikipedia)")' body, nwiki = re.subn(r'\[((?:[^\[\]]|\[[^\]]*\])*)\]\(!W(?:\s+"([^"]*)")?\)', wiki, body) # point the document's mentions of the prototype at the copy in this page body = must_replace(body, "A playable prototype implements most of it, with the whole campaign of 148 gardens and their poems.", "A playable prototype implements most of it, with the whole campaign of 148 gardens and their poems: [play it below](#play).") body = must_replace(body, "[Prototype.]{.margin-note} A one-page HTML prototype, included with the code, implements", "[Prototype.]{.margin-note} The one-page prototype [at the top of this page](#play) implements") body = must_replace(body, "Everything is reproducible from the attached code.", "Everything is reproducible from the code, which is in the [appendix](#code).") html_doc = subprocess.run(["pandoc", "-f", "markdown+smart", "-t", "html5", "--section-divs", "--wrap=none"], input=body, capture_output=True, text=True, check=True).stdout soup = BeautifulSoup(html_doc, "html.parser") # figures: embed the SVG renders as data URIs nimg = 0 for img in soup.find_all("img"): src = img.get("src", "") mo = re.match(r"^/doc/design/(.+)\.png$", src) if not mo: sys.exit(f"unexpected image {src}") svg = open(f"{FIG}/{mo.group(1)}.svg", "rb").read() img["src"] = "data:image/svg+xml;base64," + base64.b64encode(svg).decode() img["decoding"] = "async" if not img.get("alt"): img["alt"] = "" nimg += 1 # site-relative links -> gwern.net for a in soup.find_all("a", href=True): if a["href"].startswith("/"): a["href"] = "https://gwern.net" + a["href"] # collapsed sections -> for sec in soup.find_all("section", class_="collapse"): head = sec.find(re.compile(r"^h[1-6]$")) det = soup.new_tag("details", attrs={"class": "collapse " + " ".join(c for c in sec.get("class", []) if c.startswith("level")), "id": sec.get("id", "")}) summ = soup.new_tag("summary") head.extract() if head.get("class"): del head["class"] head["id"] = (sec.get("id") or "") + "-h" summ.append(head) det.append(summ) for child in list(sec.children): det.append(child.extract()) sec.replace_with(det) # a footnote's reference shows the footnote itself on hover (gwern.net shows it in a popup); a collapsed section's summary says what it does for a in soup.find_all("a", class_="footnote-ref", href=True): fn = soup.find(id=a["href"].lstrip("#")) if fn: a["title"] = re.sub(r"\s*↩︎?\s*$", "", " ".join(fn.get_text(" ").split())) for summ in soup.find_all("summary"): summ["title"] = "Click to show or hide this section" # tables scroll sideways on narrow screens for t in soup.find_all("table"): wrap = soup.new_tag("div", attrs={"class": "table-scroll"}) t.wrap(wrap) # the abstract and what follows it abstract = soup.find("div", class_="abstract") abstract.extract() first_p = soup.find("p") first_p["class"] = first_p.get("class", []) + ["dropcap"] # table of contents from the level-1 and level-2 headings toc_items = [] for h in soup.find_all(["h1", "h2"]): hid = h.get("id") or (h.find_parent(["section", "details"]) or {}).get("id") if h.get("id", "").endswith("-h"): hid = h["id"][:-2] if not hid: continue toc_items.append((h.name, hid, h.get_text(" ", strip=True))) APPENDIX_TITLE = "Appendix: Code and Data" toc_items.append(("h1", "code", APPENDIX_TITLE)) # built in part 4 toc = ['Contents
    ', '
  1. Play the prototype
  2. '] open_sub = False for lvl, hid, text in toc_items: if lvl == "h1": if open_sub: toc.append("
") open_sub = False toc.append(f'
  • {html.escape(text)}') toc.append("
      ") open_sub = True else: toc.append(f'
    1. {html.escape(text)}
    2. ') if open_sub: toc.append("
  • ") toc.append("") toc_html = re.sub(r"
  • ]*)>([^<]*)
    1. ", r"
    2. \2
    3. ", "".join(toc)) # ---------------------------------------------------------------- 2. the game, from the prototype template tpl = open(f"{ROOT}/proto/samon.template.html").read() gardens = json.load(open(f"{ROOT}/out/proto_gardens.json")) sys.path.insert(0, f"{ROOT}/src") from poems import attach from sound_pieces import pieces_html practice_poem = attach(gardens) # each garden's poem, and the practice garden's (poems/samon-poems.json) game_css = tpl[tpl.index("")] game_markup = tpl[tpl.index(''): tpl.index("")] game_js = must_replace(game_js, "__GARDENS__", json.dumps(gardens, separators=(",", ":"), ensure_ascii=False)) game_js = must_replace(game_js, "__PRACTICE_POEM__", json.dumps(practice_poem, separators=(",", ":"), ensure_ascii=False)) DAILY_PATH = f"{ROOT}/out/daily_gardens.json" daily = json.load(open(DAILY_PATH)) if os.path.exists(DAILY_PATH) else [] if not daily: print("warning: no daily gardens (out/daily_gardens.json missing)") game_js = must_replace(game_js, "__DAILY__", json.dumps(daily, separators=(",", ":"), ensure_ascii=False)) game_js = must_replace(game_js, "const VIEW_EL = document.body;", "const VIEW_EL = document.getElementById(\"play\");") if "document.body.classList" in game_js: sys.exit("the game puts a view class on document.body; the single-file edition scopes its styles to #play, so use VIEW_EL") game_js = must_replace(game_js, "window.claude?.hot?.snapshot?.(() => ({ idx, view, path: mode === \"draw\" ? path : [] }));\n", "") game_js = must_replace(game_js, "window.claude?.hot?.ready ? window.claude.hot.ready(start) : start(window.claude?.hot?.data ?? {});", "start({});") if "= 1e6 else f"{n / 1e3:.1f} kB" if n >= 1e3 else f"{n} bytes" def fid(p): return "file-" + re.sub(r"[^A-Za-z0-9]+", "-", p).strip("-").lower() readme_html = subprocess.run(["pandoc", "-f", "markdown+smart", "-t", "html5", "--wrap=none", "--shift-heading-level-by=2", "--id-prefix=readme-"], input=open(f"{BUNDLE}/README.md").read(), capture_output=True, text=True, check=True).stdout readme_html = re.sub(r"]*>.*?\s*", "", readme_html, count=1, flags=re.S) # its title; the appendix has one grouped = {g: [] for g, _ in GROUPS} for p in files: g = next((g for g, _ in GROUPS[1:] if p.startswith(g)), "") grouped[g].append(p) if sum(map(len, grouped.values())) != len(files): sys.exit("a file fell outside the appendix's groups") shown = archived = 0 parts = [] for g, title in GROUPS: if not grouped[g]: continue gid = "code-" + (g.strip("/") or "guide") parts.append(f'

      {html.escape(title)}{f" ({g})" if g else ""}

      ') rows = [] for p in grouped[g]: raw = open(f"{BUNDLE}/{p}", "rb").read() ext = os.path.splitext(p)[1] try: text = raw.decode("utf-8") except UnicodeDecodeError: text = None inline = text is not None and (ext in SHOWN_EXT or p in SHOWN_ALSO) size = human(len(raw)) if inline: shown += 1 lines = text.count("\n") + (not text.endswith("\n")) desc = describe(p, text) parts.append(f'{html.escape(p)}' f' · {lines:,} line{"s" if lines != 1 else ""} · {size}' f'{f" — {html.escape(desc)}" if desc else ""}' f'{html.escape(text, quote=False)}') else: archived += 1 why = ("figure source; the figure is shown in the document" if ext == ".svg" and p.startswith("fig/") else "image" if ext == ".png" else "binary" if text is None else "data") rows.append(f'
    4. {html.escape(p)} · {size} · {why}; in the .zip
    5. ') if rows: parts.append(f'
        {"".join(rows)}
      ') start = "".join(f'{p}{s}' for p, s in ( ("src/zgsolve.c", " (exact search), "), ("src/zg.py", " (the rules, the solver driver and a brute-force checker), "), ("src/zcp.py", " (CP-SAT par proofs), "), ("src/grader.py", " (the deductive grader), and "), ("src/verify_doc.py", ", which re-derives every number in the document"))) appendix_html = f"""

      {APPENDIX_TITLE}

      Every file behind this page: the solvers, the experiments, the level generator, the prototype's source and its tests, this edition's build script and test, the poems, and the data they wrote. The code, the logs and the poems are shown below; the data, the figures' sources and the campaign cache are listed, and everything is in the download.

      Download all {len(files)} files as one .zip ({human(len(zip_bytes))}; SHA-256 {zip_sha}).

      Start with {start}. The build and test commands are in the guide below. Not included: this page itself and the prototype's two pages, which standalone/build_standalone.py and proto/build.py write.

      {readme_html} {"".join(parts)} """ print(f"appendix: {len(files)} files ({shown} shown, {archived} only in the .zip); .zip {len(zip_bytes):,} bytes") DOWNLOAD_JS = r""" (function () { // the appendix's download: the viewer's save prompt inside claude.ai, a plain download elsewhere const a = document.getElementById("bundle-dl"), msg = document.getElementById("bundle-msg"); if (!a) return; const name = a.getAttribute("download"); const viewer = (window.claude && typeof window.claude.use === "function") ? window.claude.use("downloads").catch(() => null) : Promise.resolve(null); let blob = null; async function zip() { // the .zip is a data: URL in the page, or (in a gwtar build) a member of the archive beside it: samonAsset reads either if (!blob) blob = new Blob([await samonAsset.bytes(samonAsset.text(document.getElementById("bundle-zip")))], { type: "application/zip" }); return blob; } a.addEventListener("click", async ev => { ev.preventDefault(); msg.textContent = ""; const dl = await viewer; let z; try { z = await zip(); } catch (e) { msg.textContent = " [The .zip could not be read from this copy of the page.]"; return; } if (dl) { try { await dl.save({ filename: name, data: z }); msg.textContent = " [Saved.]"; } catch (e) { if (!e || e.code !== "declined") msg.textContent = ` [It could not be saved here (${(e && e.code) || "error"}).]`; } return; } const url = URL.createObjectURL(z), t = document.createElement("a"); t.href = url; t.download = name; document.body.appendChild(t); t.click(); t.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); }); function reveal() { // a link to a file (or to anything inside a collapsed section) opens what hides it const id = decodeURIComponent(location.hash.slice(1)), t = id && document.getElementById(id); if (!t) return; let opened = false; if (t.matches("details.file") && !t.open) { t.open = true; opened = true; } for (let el = t.parentElement; el; el = el.parentElement) if (el.tagName === "DETAILS" && !el.open) { el.open = true; opened = true; } if (opened) t.scrollIntoView({ block: "start" }); } window.addEventListener("hashchange", reveal); reveal(); })(); """ # ---------------------------------------------------------------- 4. the page PAGE_CSS = r""" /* gwern.net's manner (gwern.net/design): black on white, greys for the secondary, square corners, hairlines, serif text with sans apparatus. No web fonts: gwern.net's own stacks, with the system's CJK fonts for 砂紋, the Japanese and 静. */ :root { --font-text: var(--font-display); --plate: #ffffff; --col: 42rem; } * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; } body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--font-text); font-size: 1.0625rem; line-height: 1.6; padding: 0 16px; } a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--fg) 35%, transparent); text-underline-offset: 2px; } a:hover { color: var(--muted); text-decoration-color: var(--muted); } .page { max-width: var(--col); margin: 0 auto; padding: 28px 0 64px; } .topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; font-family: var(--font-ui); font-size: 0.8125rem; color: var(--muted); } .topbar .jump a { margin-right: 14px; } .theme { display: inline-flex; border: 1px solid var(--line); overflow: hidden; } .theme button { font: inherit; background: transparent; color: var(--muted); border: 0; border-radius: 0; padding: 4px 10px; cursor: pointer; } .theme button:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } .theme button[aria-pressed="true"] { background: var(--fg); color: var(--bg); } h1.title { font-family: var(--font-display); font-weight: 700; font-size: clamp(1.9rem, 6vw, 2.6rem); line-height: 1.15; margin: 22px 0 8px; text-wrap: balance; } .subtitle { color: var(--muted); font-style: italic; font-size: 0.98rem; margin: 0 0 10px; } .meta { font-family: var(--font-ui); font-size: 0.8125rem; color: var(--muted); margin-bottom: 22px; } .abstract { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 14px 0 4px; margin: 0 0 26px; font-size: 1rem; } .abstract p { margin: 0 0 12px; } #play { margin: 0 auto 30px; padding: 18px 0 6px; border-bottom: 1px solid var(--line); font-family: var(--font-ui); font-size: var(--step-0); line-height: 1.55; } #play .wrap { padding-block: 0 18px; } .play-head { font-family: var(--font-ui); font-size: 0.8125rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); max-width: 600px; margin: 0 auto 8px; } .toc { border: 1px solid var(--line); background: color-mix(in srgb, var(--fg) 3%, transparent); padding: 12px 16px; margin: 0 0 28px; font-family: var(--font-ui); font-size: 0.875rem; line-height: 1.5; } .toc-title { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; } .toc ol { margin: 0; padding-left: 1.3em; } .toc ol ol { padding-left: 1.1em; color: var(--muted); } @media (min-width: 700px) { .toc > ol { columns: 2; column-gap: 28px; } .toc > ol > li { break-inside: avoid; } } .doc h1, .doc h2, details.collapse summary h1, details.collapse summary h2 { font-family: var(--font-display); font-weight: 700; line-height: 1.25; } .doc h1 { font-size: 1.6rem; margin: 2.2em 0 0.6em; border-bottom: 1px solid #888; padding-bottom: 4px; } .doc h2 { font-size: 1.25rem; margin: 1.8em 0 0.5em; } .doc p { margin: 0 0 0.9em; position: relative; } .doc p.dropcap::first-letter { float: left; font-family: var(--font-display); font-size: 3.3em; line-height: 0.85; padding: 0.06em 0.08em 0 0; color: var(--fg); } .margin-note { font-family: var(--font-ui); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--muted); } .margin-note::after { content: " "; } @media (min-width: 1180px) { .doc p > .margin-note:first-child, .doc li > .margin-note:first-child { position: absolute; left: -12.5rem; width: 11rem; text-align: right; top: 0.25em; line-height: 1.35; font-weight: 600; } } .doc ul, .doc ol { padding-left: 1.4em; } .doc li { margin-bottom: 0.35em; } .doc figure { margin: 1.4em 0; } .doc figure img { display: block; width: 100%; height: auto; background: var(--plate); border: 1px solid var(--line); } .doc figure img:not(.width-full) { max-width: 30rem; margin: 0 auto; } .doc figcaption { font-size: 0.9rem; color: var(--muted); margin-top: 8px; line-height: 1.45; } .table-scroll { overflow-x: auto; margin: 1.2em 0; } .doc table { border-collapse: collapse; width: 100%; font-family: var(--font-ui); font-size: 0.82rem; font-variant-numeric: tabular-nums; } .doc caption { caption-side: bottom; text-align: left; color: var(--muted); font-size: 0.8rem; padding-top: 6px; font-family: var(--font-text); } .doc th, .doc td { padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } .doc thead th { border-bottom: 2px solid var(--fg); } .doc tbody tr:nth-child(odd) { background: color-mix(in srgb, var(--fg) 3.5%, transparent); } details.collapse { margin: 2em 0 1em; border-top: 1px solid var(--line); } details.collapse > summary { cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: 10px; } details.collapse > summary::-webkit-details-marker { display: none; } details.collapse > summary::before { content: "▸"; font-family: var(--font-ui); color: var(--muted); } details.collapse[open] > summary::before { content: "▾"; } details.collapse > summary h1 { font-size: 1.6rem; margin: 0.6em 0; } details.collapse > summary h2 { font-size: 1.25rem; margin: 0.6em 0; } details.collapse > summary::after { content: "[click to expand]"; font-family: var(--font-ui); font-size: 0.75rem; color: var(--muted); } details.collapse[open] > summary::after { content: ""; } .abstract-collapse { font-style: italic; color: var(--muted); } .footnotes { font-size: 0.9rem; border-top: 1px solid var(--line); margin-top: 2.5em; padding-top: 0.5em; } .footnotes hr { display: none; } sup { line-height: 0; } .colophon { font-family: var(--font-ui); font-size: 0.78rem; color: var(--muted); border-top: 1px solid var(--line); margin-top: 3em; padding-top: 10px; } /* the appendix: code listings */ .appendix h2 { font-size: 1.15rem; } .appendix .readme { font-size: 0.92rem; } .appendix .readme h4 { font-family: var(--font-ui); font-size: 0.8rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin: 1.4em 0 0.4em; } .appendix code, .appendix pre { font-family: "IBM Plex Mono", "Liberation Mono", Consolas, Menlo, Courier, monospace; } .appendix :not(pre) > code { font-size: 0.86em; } .appendix .sha { word-break: break-all; font-size: 0.78em; } .appendix .bundle { font-family: var(--font-ui); font-size: 0.95rem; } details.file { border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent); } details.file > summary { cursor: pointer; font-family: var(--font-ui); font-size: 0.86rem; line-height: 1.45; padding: 5px 0; } details.file > summary code { font-size: 0.95em; } .file-meta, .file-desc { color: var(--muted); } details.file pre { margin: 4px 0 12px; padding: 10px 12px; font-size: 0.74rem; line-height: 1.45; tab-size: 4; overflow-x: auto; background: color-mix(in srgb, var(--fg) 4%, transparent); border: 1px solid var(--line); } .file-list { font-family: var(--font-ui); font-size: 0.82rem; columns: 1; } .doc .file-list li { margin-bottom: 0.15em; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --plate: #f4f5f2; } } :root[data-theme="dark"] { --plate: #f4f5f2; } """ THEME_JS = r""" (function () { const root = document.documentElement, KEY = "samon-theme"; const btns = document.querySelectorAll(".theme button"); function apply(t) { if (t === "light" || t === "dark") root.setAttribute("data-theme", t); else root.removeAttribute("data-theme"); btns.forEach(b => b.setAttribute("aria-pressed", String(b.dataset.t === (t || "auto")))); } let saved = "auto"; try { saved = localStorage.getItem(KEY) || "auto"; } catch (e) {} apply(saved); btns.forEach(b => b.addEventListener("click", () => { apply(b.dataset.t); try { localStorage.setItem(KEY, b.dataset.t); } catch (e) {} })); })(); """ dates = f"{meta.get('created', '')}–{meta.get('modified', '')}" title_html = re.sub(r"_([^_]+)_", r"\1", html.escape(meta["title"])) title_text = re.sub(r"_", "", meta["title"]) # Social cards (Open Graph; Twitter reads the og: tags too, plus its own card type). The thumbnail is a garden raked at par # (proto/test/thumbnail.py -> deliver/2026-10-05-gwern-samon-thumbnail.png); crawlers need an absolute URL, so this is where # gwern.net would host it: adjust THUMB_URL when the page is placed. THUMB_URL = "https://gwern.net" + meta["thumbnail"] # (the document's own thumbnail field) SOCIAL_SUMMARY = ("A Zen-garden raking puzzle: a novice monk walks into the sand and must rake his way back out to the gate in one stroke. " "The full design, solver experiments, 148 gardens and a year of daily ones, and a playable prototype, in one page.") SOCIAL_META = f""" """ page = f""" {html.escape(title_text)} {SOCIAL_META} PlayContentsMethodsCode AutoLightDark

      {title_html}

      {html.escape(meta['description'])}

      {html.escape(meta['author'])} · {dates} · status: {html.escape(meta.get('status', ''))} · confidence: {html.escape(meta.get('confidence', ''))} {str(abstract)} [The prototype: {len(gardens)} gardens, playable here] {game_markup} {toc_html} {str(soup)} {appendix_html}

      [Single-file edition: the design document, the playable prototype, and all the code and data behind them in one self-contained page (figures and files embedded; no fonts, scripts, or images loaded from elsewhere). The solvers, experiments, tests and data are in the appendix.]

      """ open(OUT, "w").write(page) print(f"wrote {OUT}: {len(page):,} bytes; {nwiki} Wikipedia links resolved; {nimg} figures embedded; {len(toc_items)} TOC entries") standalone/make_test_standalone.py — Generate standalone/test_standalone.py from proto/test/run.py: the same prototype tests, run against the single-file """Generate standalone/test_standalone.py from proto/test/run.py: the same prototype tests, run against the single-file edition (the game inside the document), plus checks of the document itself (no outside requests, figures, structure, collapsed sections, contents links, theme switch).""" import sys MARGIN_NOTES = 70 # margin notes in samon.md: update when the document gains or loses one src = open('/home/claude/zen/proto/test/run.py').read() def rep(s, old, new, count=1): n = s.count(old) if n != count: sys.exit(f"expected {count} of {old[:60]!r}, found {n}") return s.replace(old, new) s = rep(src, "URL = 'file:///home/claude/zen/proto/test/index.html'", "URL = 'file:///home/claude/zen/standalone/2026-10-03-gwern-samon.html'") s = rep(s, "SHOT = '/home/claude/zen/proto/test/'", "SHOT = '/home/claude/zen/standalone/shots/'") s = rep(s, "async def centers(page, cells):\n", "async def centers(page, cells):\n await page.evaluate(\"(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); if (r.top < 0 || r.bottom > innerHeight) cv.scrollIntoView({block: 'center'}); })()\")\n") first_goto = " await page.goto(URL); await page.wait_for_timeout(300)\n" i = s.index(first_goto) block = f''' outside = [] page.on('request', lambda r: outside.append(r.url) if not r.url.startswith('file://') and not r.url.startswith('data:') else None) await page.goto(URL); await page.wait_for_timeout(500) expect(not outside, f'no requests beyond the file itself {{outside[:3]}}') imgs = await page.evaluate("[...document.querySelectorAll('.doc img')].map(i => i.complete && i.naturalWidth > 0)") expect(len(imgs) == 12 and all(imgs), f'all 12 figures decode ({{sum(imgs)}}/{{len(imgs)}})') n = await page.evaluate("[document.querySelectorAll('.doc h1, .doc h2, details summary h1, details summary h2').length, document.querySelectorAll('.margin-note').length, document.querySelectorAll('.footnote-ref').length, document.querySelectorAll('.toc a').length]") expect(n[0] >= 20 and n[1] == {MARGIN_NOTES} and n[2] == 2 and n[3] == 23, f'document structure (headings, margin notes, footnote refs, TOC links) {{n}}') # the appendix: every file of the code bundle is in the page's .zip, which is the one the build wrote; the code # shown inline is byte-for-byte the files'; the download link delivers the .zip import base64, hashlib, io, zipfile ZP = '/home/claude/zen/standalone/2026-10-03-gwern-samon-code.zip' BUNDLE = '/home/claude/zen/deliver/samon-code' ap = await page.evaluate("""(() => {{ const shown = {{}}; document.querySelectorAll('details.file').forEach(d => {{ shown[d.dataset.path] = d.querySelector('pre code').textContent; }}); return [shown, document.querySelectorAll('.appendix .file-list li').length, document.getElementById('bundle-zip').textContent.trim()]; }})()""") expect(ap[2].startswith('data:application/zip;base64,'), 'the .zip is carried as a data: URL (which a gwtar build moves into its archive)') zb = base64.b64decode(ap[2].split(',', 1)[1]); zdisk = open(ZP, 'rb').read() expect(zb == zdisk, f'the page carries the .zip the build wrote ({{len(zb):,}} bytes)') zf = zipfile.ZipFile(io.BytesIO(zb)) names = [z_[len('samon-code/'):] for z_ in zf.namelist()] on_disk = sorted(os.path.relpath(os.path.join(d_, f_), BUNDLE) for d_, _, fs_ in os.walk(BUNDLE) for f_ in fs_) gen_ = {{'standalone/2026-10-03-gwern-samon.html', 'proto/samon.html', 'proto/samon.standalone.html'}} expect(names == [p_ for p_ in on_disk if p_ not in gen_], f'the .zip holds every file of the bundle but the generated pages ({{len(names)}} files)') expect(all(zf.read('samon-code/' + p_) == open(os.path.join(BUNDLE, p_), 'rb').read() for p_ in names), 'every file in the .zip is byte-for-byte the bundle\\'s') expect(len(ap[0]) + ap[1] == len(names) and all(zf.read('samon-code/' + p_).decode() == t_ for p_, t_ in ap[0].items()), f'the appendix shows or lists every file, and what it shows is the file exactly ({{len(ap[0])}} shown, {{ap[1]}} listed)') expect(all(p_ in ap[0] for p_ in names if p_.endswith(('.py', '.c', '.md')) or p_ == 'proto/samon.template.html'), 'all the code is shown') await page.click('summary:has(h1:text("Appendix: Code and Data"))') async with page.expect_download() as dli_: await page.click('#bundle-dl') dl_ = await dli_.value got_ = open(await dl_.path(), 'rb').read() expect(dl_.suggested_filename == '2026-10-03-gwern-samon-code.zip' and hashlib.sha256(got_).hexdigest() == hashlib.sha256(zdisk).hexdigest(), f'the download link delivers the .zip ({{dl_.suggested_filename}})') await page.evaluate("location.hash = '#file-src-zgsolve-c'"); await page.wait_for_timeout(200) op_ = await page.evaluate("[document.getElementById('file-src-zgsolve-c').open, Math.round(document.getElementById('file-src-zgsolve-c').getBoundingClientRect().top)]") expect(op_[0] and abs(op_[1]) < 5, f'a link to a file opens it and scrolls to it {{op_}}') await page.evaluate("document.getElementById('file-src-zgsolve-c').open = false; history.replaceState(null, '', location.pathname)") await page.click('summary:has(h1:text("Appendix: Code and Data"))') await page.click('summary:has(h1:text("Methods"))') op = await page.evaluate("document.getElementById('methods').open") expect(op, 'Methods expands') await page.click('summary:has(h1:text("Methods"))') await page.click('.toc >> text=The Wide Rake') y = await page.evaluate("document.getElementById('the-wide-rake').getBoundingClientRect().top") expect(abs(y) < 5, f'TOC links jump to their sections ({{y:.0f}})') await page.evaluate("window.scrollTo(0, 0)") await page.click('.theme >> text=Dark') th = await page.evaluate("[document.documentElement.getAttribute('data-theme'), getComputedStyle(document.body).backgroundColor]") expect(th[0] == 'dark' and th[1] == 'rgb(22, 22, 22)', f'theme switch to dark {{th}}') await page.click('.theme >> text=Auto') await page.evaluate("document.getElementById('garden').scrollIntoView({{block: 'center'}})") ''' s = s[:i] + block + s[i + len(first_goto):] s = rep(s, " await page.click('text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view\n", " await page.click('#play nav >> text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view\n" " await page.evaluate(\"document.getElementById('garden').scrollIntoView({block: 'center'})\")\n") s = rep(s, " await page.evaluate(\"window.__samon.enterPractice()\")\n", " await page.evaluate(\"window.__samon.enterPractice()\")\n await page.evaluate(\"document.getElementById('garden').scrollIntoView({block: 'center'})\")\n", count=2) open('/home/claude/zen/standalone/test_standalone.py', 'w').write(s) print('wrote standalone/test_standalone.py') standalone/shots.py import asyncio from playwright.async_api import async_playwright URL = 'file:///home/claude/zen/standalone/2026-10-03-gwern-samon.html' async def main(): async with async_playwright() as p: b = await p.chromium.launch() # phone, light: top of page, then the game page = await (await b.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=2)).new_page() await page.goto(URL); await page.wait_for_timeout(400) await page.screenshot(path='shots/phone-top.png') await page.evaluate("document.getElementById('play').scrollIntoView()"); await page.wait_for_timeout(200) await page.screenshot(path='shots/phone-game.png') await page.evaluate("document.getElementById('the-wide-rake').scrollIntoView()"); await page.wait_for_timeout(200) await page.screenshot(path='shots/phone-doc.png') # desktop, light: margin notes in the margin, a table page = await (await b.new_context(viewport={'width': 1400, 'height': 900})).new_page() await page.goto(URL); await page.wait_for_timeout(400) await page.evaluate("document.getElementById('the-bare-rule-is-loose').scrollIntoView()"); await page.wait_for_timeout(200) await page.screenshot(path='shots/desktop-doc.png') # desktop, dark: calm section with figure ctx = await b.new_context(viewport={'width': 1400, 'height': 900}, color_scheme='dark') page = await ctx.new_page(); await page.goto(URL); await page.wait_for_timeout(400) await page.evaluate("document.getElementById('calm-turns-as-score').scrollIntoView()"); await page.wait_for_timeout(200) await page.screenshot(path='shots/desktop-dark.png') await page.evaluate("window.scrollTo(0,0)"); await page.wait_for_timeout(200) await page.screenshot(path='shots/desktop-dark-top.png') await b.close() asyncio.run(main()) standalone/test_standalone.py — Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals; """Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals; Restore after a rewind and after Start over; the abbot's hints (first hint, sketched groove, 'no longer at par' with its rewind, 'stuck' with its rewind); the grey seal; unlocking; assists; the poems and the scroll of poems earned; the daily garden; drag intent (drift, the grip beyond the walls, arcs, reversals, wobble); sound; sharing (a raking as a code in the URL's hash, progress carried between devices, the share text); the practice garden; screenshots in light and dark.""" import asyncio, json, math, os, sys from playwright.async_api import async_playwright sys.path.insert(0, '/home/claude/zen/src') from zg import Garden, check_solution, turns_of G = json.load(open('/home/claude/zen/out/proto_gardens.json')) URL = 'file:///home/claude/zen/standalone/2026-10-03-gwern-samon.html' SHOT = '/home/claude/zen/standalone/shots/' fails = [] def garden_of(p, braced=True): marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()} clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()} return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None, nohairpin=2 if p['wide'] else 0, braced_start=p['wide'] and braced, end_side=p['end_side']) def expect(cond, what): print(('ok ' if cond else 'FAIL ') + what) if not cond: fails.append(what) async def centers(page, cells): await page.evaluate("(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); if (r.top < 0 || r.bottom > innerHeight) cv.scrollIntoView({block: 'center'}); })()") return await page.evaluate("""(path) => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); const s = r.width / cv.width; const g = window.__samon.geom(); return path.map(([rr, cc]) => [r.left + (g.ox + cc * g.cs + g.cs / 2) * s, r.top + (g.oy + rr * g.cs + g.cs / 2) * s]); }""", cells) async def drag(page, cells): pts = await centers(page, cells) await page.mouse.move(*pts[0]); await page.mouse.down() for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3) await page.mouse.up() async def quiet(page, **t): # poems off (they would carry the page on to the next garden), unless a test wants them await page.evaluate("(t) => { window.__samon.poemTiming = t; }", t or {'delay': 3600000}) async def state(page): return await page.evaluate("() => ({mode: window.__samon.mode, len: window.__samon.path.length, turns: window.__samon.turnsOf(), " "status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''), " "restore: !document.getElementById('restore').hidden, seal: getComputedStyle(document.getElementById('seal')).display, " "ghosts: window.__samon.ghosts.size, actions: [...document.querySelectorAll('#actions button')].map(b => b.textContent)})") async def main(): async with async_playwright() as p: b = await p.chromium.launch() errors = [] ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page() page.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None) page.on('pageerror', lambda e: errors.append(str(e))) outside = [] page.on('request', lambda r: outside.append(r.url) if not r.url.startswith('file://') and not r.url.startswith('data:') else None) await page.goto(URL); await page.wait_for_timeout(500) expect(not outside, f'no requests beyond the file itself {outside[:3]}') imgs = await page.evaluate("[...document.querySelectorAll('.doc img')].map(i => i.complete && i.naturalWidth > 0)") expect(len(imgs) == 12 and all(imgs), f'all 12 figures decode ({sum(imgs)}/{len(imgs)})') n = await page.evaluate("[document.querySelectorAll('.doc h1, .doc h2, details summary h1, details summary h2').length, document.querySelectorAll('.margin-note').length, document.querySelectorAll('.footnote-ref').length, document.querySelectorAll('.toc a').length]") expect(n[0] >= 20 and n[1] == 69 and n[2] == 2 and n[3] == 23, f'document structure (headings, margin notes, footnote refs, TOC links) {n}') # the appendix: every file of the code bundle is in the page's .zip, which is the one the build wrote; the code # shown inline is byte-for-byte the files'; the download link delivers the .zip import base64, hashlib, io, zipfile ZP = '/home/claude/zen/standalone/2026-10-03-gwern-samon-code.zip' BUNDLE = '/home/claude/zen/deliver/samon-code' ap = await page.evaluate("""(() => { const shown = {}; document.querySelectorAll('details.file').forEach(d => { shown[d.dataset.path] = d.querySelector('pre code').textContent; }); return [shown, document.querySelectorAll('.appendix .file-list li').length, document.getElementById('bundle-zip').textContent.trim()]; })()""") expect(ap[2].startswith('data:application/zip;base64,'), 'the .zip is carried as a data: URL (which a gwtar build moves into its archive)') zb = base64.b64decode(ap[2].split(',', 1)[1]); zdisk = open(ZP, 'rb').read() expect(zb == zdisk, f'the page carries the .zip the build wrote ({len(zb):,} bytes)') zf = zipfile.ZipFile(io.BytesIO(zb)) names = [z_[len('samon-code/'):] for z_ in zf.namelist()] on_disk = sorted(os.path.relpath(os.path.join(d_, f_), BUNDLE) for d_, _, fs_ in os.walk(BUNDLE) for f_ in fs_) gen_ = {'standalone/2026-10-03-gwern-samon.html', 'proto/samon.html', 'proto/samon.standalone.html'} expect(names == [p_ for p_ in on_disk if p_ not in gen_], f'the .zip holds every file of the bundle but the generated pages ({len(names)} files)') expect(all(zf.read('samon-code/' + p_) == open(os.path.join(BUNDLE, p_), 'rb').read() for p_ in names), 'every file in the .zip is byte-for-byte the bundle\'s') expect(len(ap[0]) + ap[1] == len(names) and all(zf.read('samon-code/' + p_).decode() == t_ for p_, t_ in ap[0].items()), f'the appendix shows or lists every file, and what it shows is the file exactly ({len(ap[0])} shown, {ap[1]} listed)') expect(all(p_ in ap[0] for p_ in names if p_.endswith(('.py', '.c', '.md')) or p_ == 'proto/samon.template.html'), 'all the code is shown') await page.click('summary:has(h1:text("Appendix: Code and Data"))') async with page.expect_download() as dli_: await page.click('#bundle-dl') dl_ = await dli_.value got_ = open(await dl_.path(), 'rb').read() expect(dl_.suggested_filename == '2026-10-03-gwern-samon-code.zip' and hashlib.sha256(got_).hexdigest() == hashlib.sha256(zdisk).hexdigest(), f'the download link delivers the .zip ({dl_.suggested_filename})') await page.evaluate("location.hash = '#file-src-zgsolve-c'"); await page.wait_for_timeout(200) op_ = await page.evaluate("[document.getElementById('file-src-zgsolve-c').open, Math.round(document.getElementById('file-src-zgsolve-c').getBoundingClientRect().top)]") expect(op_[0] and abs(op_[1]) < 5, f'a link to a file opens it and scrolls to it {op_}') await page.evaluate("document.getElementById('file-src-zgsolve-c').open = false; history.replaceState(null, '', location.pathname)") await page.click('summary:has(h1:text("Appendix: Code and Data"))') await page.click('summary:has(h1:text("Methods"))') op = await page.evaluate("document.getElementById('methods').open") expect(op, 'Methods expands') await page.click('summary:has(h1:text("Methods"))') await page.click('.toc >> text=The Wide Rake') y = await page.evaluate("document.getElementById('the-wide-rake').getBoundingClientRect().top") expect(abs(y) < 5, f'TOC links jump to their sections ({y:.0f})') await page.evaluate("window.scrollTo(0, 0)") await page.click('.theme >> text=Dark') th = await page.evaluate("[document.documentElement.getAttribute('data-theme'), getComputedStyle(document.body).backgroundColor]") expect(th[0] == 'dark' and th[1] == 'rgb(22, 22, 22)', f'theme switch to dark {th}') await page.click('.theme >> text=Auto') await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})") await quiet(page) st = await state(page) expect(st['seal'] == 'none' and not st['restore'] and not await page.query_selector('#rake'), 'seal and Restore hidden at load; no Rake button') # 0. a first visit: the first garden is raked once by the monk (walking in, then raking out) and reset for the player; # then the monk waits in the gateway, a ring beckons, and a ghostly finger shows the drag onboard = """() => ({beckon: !document.getElementById('beckon').hidden, finger: !document.getElementById('finger').hidden, anim: document.getElementById('finger').getAnimations().length, status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''), started: localStorage.getItem('samon-started')})""" g0 = G[0] expect(g0['demo'] and G[1]['demo'] and not G[2]['demo'], 'the first two gardens have demonstrations, the first real garden (third) none') await page.wait_for_function("window.__samon.mode === 'demo'", timeout=4000) st = await state(page); ob = await page.evaluate(onboard) expect(st['status'].startswith('Watch') and not ob['beckon'], f"the first garden demonstrates itself, and nothing beckons meanwhile ({st['status']!r})") await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=8000) walked = await page.evaluate("window.__samon.path.slice()") want = await page.evaluate("(cells) => cells.map(([r, c]) => window.__samon.key(r, c))", g0['intended']) expect(walked == want, 'the demonstration walks the intended raking, then rakes it') await page.wait_for_function("!window.__samon.demoRunning", timeout=20000) st = await state(page); ob = await page.evaluate(onboard) pill0 = await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(g0['name'])}).className") expect(st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'] and st['seal'] == 'none' and 'done' not in pill0 and 'sealed' not in pill0, f"then the garden is reset for the player, and the demonstration counts for nothing ({st['status']!r}, {pill0!r})") expect(ob['beckon'] and ob['finger'] and ob['anim'] == 1, f'then the waiting monk beckons, and a finger shows the drag {ob}') br = await page.evaluate("[document.getElementById('status').textContent, document.querySelector('.help.puzzle-only').textContent.trim(), document.querySelector('#nav .lock').textContent, document.getElementById('rule').firstChild.textContent, document.getElementById('rule').querySelector('.facts').textContent, [...document.querySelectorAll('#actions button, .veranda button')].map(b => b.textContent)]") expect(all(t.startswith('[') and t.endswith(']') for t in br[:3]) and br[3].startswith('[') and not br[4].startswith('[') and not any(t.startswith('[') for t in br[5]), f"what the game says to the player is set in editorial brackets (status, help, lock notes, a garden's instructions), and rules, counts and controls are not ({br[0][:40]!r}, {br[3][:30]!r}, {br[4][:20]!r})") await page.reload(); await page.wait_for_timeout(1500) await quiet(page) st = await state(page); ob = await page.evaluate(onboard) expect(not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and 'Drag the monk in' in st['status'] and ob['beckon'] and ob['finger'], f"a demonstration seen once does not play again by itself ({st['status']!r})") wv = await page.evaluate("!document.getElementById('watch').hidden") await page.click('#watch'); await page.wait_for_timeout(700) running = await page.evaluate("window.__samon.demoRunning") mid = await centers(page, [g0['intended'][1]]); await page.mouse.click(*mid[0]); await page.wait_for_timeout(100) st = await state(page) expect(wv and running and not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'], 'Watch replays the demonstration, and a tap skips it') await page.evaluate("window.__samon.load(2)") expect(await page.evaluate("document.getElementById('watch').hidden"), 'a garden without a demonstration has no Watch button') await page.evaluate("window.__samon.load(0)") far = await centers(page, [[0, len(g0['rows'][0]) - 1]]) await page.mouse.click(*far[0]); st = await state(page) expect('Start at the gate' in st['status'] and st['len'] == 0, 'a press away from the gate says where to start') gp = await page.evaluate("(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); const s = r.width / cv.width; const [x, y] = window.__samon.gate(); return [r.left + x * s, r.top + y * s]; })()") pts = await centers(page, g0['intended'][:3]) await page.mouse.move(*gp); await page.mouse.down() for (x, y) in pts: await page.mouse.move(x, y, steps=3) await page.mouse.up(); st = await state(page); ob = await page.evaluate(onboard) expect(st['len'] == 3 and not ob['beckon'] and not ob['finger'] and ob['started'] == '1', f'dragging the monk in from the gateway starts the walk, and the beckoning stops {ob}') await page.screenshot(path=SHOT + 'shot-phone-light-start.png', full_page=False) await page.evaluate("window.__samon.load(2)"); ob = await page.evaluate(onboard) expect(ob['beckon'] and not ob['finger'], f'after the first walk, fresh gardens still beckon, without the finger {ob}') # 1. every garden at par, by mouse: letting go of a complete walk rakes it for i, g in enumerate(G if not os.environ.get('SAMON_QUICK') else G[:3]): await page.evaluate(f"window.__samon.load({i})") await drag(page, g['intended']) await page.wait_for_timeout(350) st = await state(page) ok = st['len'] == len(g['intended']) and st['turns'] == g['par'] and st['mode'] == 'reveal' pts = await centers(page, [g['intended'][0]]); await page.mouse.click(*pts[0]) # skip the reveal await page.wait_for_timeout(60) st2 = await state(page) expect(ok and st2['mode'] == 'done' and st2['seal'] == 'grid' and 'par' in st2['status'], f"{g['name']}: drawn, raked on letting go, at par, sealed") # 2. refusals: wide rake U-turn; pebbles wi = next(i for i, g in enumerate(G) if g['name'] == 'Wide rake') await page.evaluate(f"window.__samon.load({wi})") await drag(page, [[6, 5], [5, 5], [4, 5], [4, 6], [5, 6]]) st = await state(page) expect('wide rake' in st['status'] and st['len'] == 4, 'wide rake refuses an open-sand U-turn') pi = next(i for i, g in enumerate(G) if g['name'] == "The abbot's pebbles") pg = G[pi]; marks = {tuple(map(int, k.split(','))): v for k, v in pg['marks'].items()} rows = pg['rows']; seq = [tuple(c) for c in pg['intended']] j = next(k for k in range(1, len(seq) - 1) if marks.get(seq[k]) == 'S' and any(0 <= seq[k][0] + a < len(rows) and 0 <= seq[k][1] + bb < len(rows[0]) and rows[seq[k][0] + a][seq[k][1] + bb] == '.' and (seq[k][0] + a, seq[k][1] + bb) not in seq[:k + 1] and (a, bb) != (seq[k][0] - seq[k - 1][0], seq[k][1] - seq[k - 1][1]) and (a, bb) != (seq[k - 1][0] - seq[k][0], seq[k - 1][1] - seq[k][1]) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1)))) v = seq[j]; din = (v[0] - seq[j - 1][0], v[1] - seq[j - 1][1]) turn = next((v[0] + a, v[1] + bb) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1)) if (a, bb) != din and (a, bb) != (-din[0], -din[1]) and 0 <= v[0] + a < len(rows) and 0 <= v[1] + bb < len(rows[0]) and rows[v[0] + a][v[1] + bb] == '.' and (v[0] + a, v[1] + bb) not in seq[:j + 1]) await page.evaluate(f"window.__samon.load({pi})") await drag(page, [list(c) for c in seq[:j + 1]] + [list(turn)]) st = await state(page) expect('pale pebble' in st['status'] and st['len'] == j + 1, 'a pale pebble refuses a turn') # 3. Restore after a tap-rewind and after Start over ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm') await page.evaluate(f"window.__samon.load({ci})") await page.click('#restart') await drag(page, G[ci]['intended'][:12]) pts = await centers(page, [G[ci]['intended'][3]]); await page.mouse.click(*pts[0]) st = await state(page) expect(st['len'] == 4 and st['restore'], 'tapping a footprint rewinds to it, and offers Restore') await page.click('#restore'); st = await state(page) expect(st['len'] == 12 and not st['restore'], 'Restore brings the rewound route back') await page.click('#restart'); st = await state(page) expect(st['len'] == 0 and st['restore'], 'Start over clears the route, and offers Restore') await page.click('#restore'); st = await state(page) expect(st['len'] == 12, 'Restore after Start over') # 3b. after raking: a tap on a cell walks back to it (the sand is smoothed back to the footprints), Restore rakes it again; # holding at the last cell does not rake until the mouse lets go; the keyboard rakes after a beat await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart') full = G[ci]['intended'] pts = await centers(page, full) await page.mouse.move(*pts[0]); await page.mouse.down() for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3) await page.wait_for_timeout(700); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == len(full), 'a complete walk is not raked while the mouse is still down') await page.mouse.up(); await page.wait_for_timeout(350); st = await state(page) expect(st['mode'] == 'reveal', 'letting go starts the raking') await page.mouse.click(*pts[0]); await page.wait_for_timeout(700) k = len(full) - 5 await page.mouse.click(*pts[k - 1]); await page.wait_for_timeout(100); st = await state(page) ref = await page.evaluate("window.__samon.sand()") expect(st['mode'] == 'draw' and st['len'] == k and st['seal'] == 'none' and st['restore'], 'after raking, tapping a cell walks back to it, and offers Restore') await page.evaluate("(w) => window.__samon.setPath(w)", full[:k]); fresh = await page.evaluate("window.__samon.sand()") expect(abs(ref - fresh) < 1e-6, 'walking back smooths the raked sand back to the footprints') await page.evaluate("(w) => window.__samon.setPath(w)", full) await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'a walk completed any other way is raked after a beat') await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(60); st = await state(page) expect(st['mode'] == 'done', 'Enter skips the raking') await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == len(full) - 1, 'Backspace after raking steps back one footprint') await page.click('#restore'); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'Restore brings the whole walk back, and it is raked again') await page.keyboard.press('Enter'); await page.wait_for_timeout(60) # the keyboard: the last arrow rakes after a beat, in which Backspace can still take it back ti = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares') await page.evaluate(f"window.__samon.load({ti})"); await page.focus('#garden') keys = ('ArrowUp', 'ArrowUp', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowUp') # in at the gate, then a serpentine for kk in keys: await page.keyboard.press(kk) await page.keyboard.press('Backspace'); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == 8, 'Backspace in the beat takes the last step back, and nothing is raked') await page.keyboard.press(keys[-1]); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] in ('reveal', 'done') and st['len'] == 9, 'from the keyboard, a complete walk is raked after a beat') await page.keyboard.press('Enter'); await page.wait_for_timeout(60); await page.keyboard.press('Enter'); await page.wait_for_timeout(800) nm = await page.evaluate("window.__samon.G.name") expect(nm == G[ti + 1]['name'], 'Enter after raking goes on to the next garden') # a walk that covers every cell but may not end where it does is not raked wi2, bad = None, None for j, gg in enumerate(G): if not gg['wide'] or gg['end']: continue gx = garden_of(gg, braced=False) for s_ in gx.solve(mode=0, cap=400, stream=True)['sols']: if not check_solution(garden_of(gg), s_['draw']): wi2, bad = j, [list(c) for c in s_['draw']]; break if bad: break await page.evaluate(f"window.__samon.load({wi2})"); await drag(page, bad); await page.wait_for_timeout(600); st = await state(page) expect(st['mode'] == 'draw' and st['len'] == len(bad) and 'wide rake down' in st['status'], f"a complete walk ending in open sand under the wide rake is not raked ({G[wi2]['name']})") # 4. the abbot's hints await page.evaluate(f"window.__samon.load({ci})") await page.click('#restart'); await page.click('#hint'); st = await state(page) expect(G[ci]['hint'] in st['status'], 'first hint is the authored observation') await page.click('#hint'); st = await state(page) expect('at par' in st['status'] and st['ghosts'] == 1, 'second hint: route can reach par, one groove sketched') found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30); const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", found) await page.click('#hint'); st = await state(page) expect('no longer at par' in st['status'] and any('for par' in a for a in st['actions']), "'no longer at par' with a rewind offer") await page.click('#actions button >> nth=0'); st = await state(page) a = await page.evaluate("window.__samon.analyze(window.__samon.path).state") expect(a == 'par' and st['restore'], 'the par rewind lands on a route that can reach par, and is restorable') fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent.replace(/^\\[|\\]$/g, '')]") n0 = await page.evaluate("window.__samon.path.length"); await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowRight'); n1 = await page.evaluate("window.__samon.path.length") expect(fk[0] == 'garden' and fk[1].startswith('Stepped back') and 'par is still possible' in fk[1] and 'no longer at par' not in fk[1] and n1 > n0, f"after the abbot's rewind the garden has the keyboard again, and the old message has given way to what now holds ({fk[1][:70]!r})") found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 8 + s % 30); const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'stuck' && a.back < w.length - 1 && a.why.cells.length) return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", found) await page.click('#hint'); st = await state(page) expect('to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), "'stuck': reason, marked cells, rewind offer, action first") await page.screenshot(path=SHOT + 'shot-phone-light-stuck.png', full_page=True) await page.click('#actions button >> nth=0') a = await page.evaluate("window.__samon.analyze(window.__samon.path).state") fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent]") expect(a in ('par', 'finish') and fk[0] == 'garden' and 'can be finished from here' in fk[1] and 'marked footprint' not in fk[1], f"the rewind lands on a route that can be finished, says so, and keeps the keyboard ({fk[1][:60]!r})") # 5. assists await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart') await drag(page, G[ci]['intended'][:20] if False else [[6, 3], [5, 3], [5, 2], [5, 1], [5, 0], [4, 0], [3, 0], [3, 1], [4, 1], [4, 2], [4, 3], [4, 4], [3, 4], [3, 5]]) for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid) pressed = await page.evaluate("['aPreview','aChecker','aCut'].map(id => document.getElementById(id).getAttribute('aria-pressed'))") expect(pressed == ['true'] * 3, 'assist toggles switch on') await page.screenshot(path=SHOT + 'shot-phone-light-assists.png', full_page=True) for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid) # 6. practice garden await page.click('#play nav >> text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})") s0 = await page.evaluate("window.__samon.practice()") box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()") L, T, W, H = box import math for k, (cx, cy, rad) in enumerate([(0.27, 0.40, 0.18), (0.72, 0.65, 0.14)]): pts = [(L + W * (cx + rad * math.cos(t / 30 * 2 * math.pi) * 0.75), T + H * (cy + rad * math.sin(t / 30 * 2 * math.pi))) for t in range(0, 34)] await page.mouse.move(*pts[0]); await page.mouse.down() for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=4) await page.mouse.up() for yy in (0.1, 0.16, 0.86, 0.92): await page.mouse.move(L + W * 0.05, T + H * yy); await page.mouse.down() await page.mouse.move(L + W * 0.95, T + H * yy, steps=40); await page.mouse.up() s1 = await page.evaluate("window.__samon.practice()") expect(abs(s1['sum'] - s0['sum']) > 50, 'raking changes the practice sand') await page.screenshot(path=SHOT + 'shot-phone-light-practice.png', full_page=True) await page.click('#toolStone'); await page.mouse.click(L + W * 0.5, T + H * 0.2) s2 = await page.evaluate("window.__samon.practice()") expect(s2['stones'] == s0['stones'] + 1, 'Stones tool places a stone') await page.mouse.click(L + W * 0.5, T + H * 0.2) s3 = await page.evaluate("window.__samon.practice()") expect(s3['stones'] == s0['stones'], 'tapping a stone lifts it') await page.click('#smooth'); s4 = await page.evaluate("window.__samon.practice()") expect(abs(s4['sum'] - s0['sum']) < 1e-6, 'Smooth restores the unraked sand') await page.click('#nav [aria-label="Calm"]'); st = await state(page) expect(await page.evaluate("window.__samon.view") == 'puzzle', 'back from the practice garden to a garden') await ctx.close() # 7. dark mode: a finished garden, and the practice garden ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='dark') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page, delay=100, hold=3600000) # the screenshot shows the raked garden with its poem si = next(i for i, g in enumerate(G) if g['name'] == 'Spiral') await page.evaluate(f"window.__samon.load({si})"); await drag(page, G[si]['intended']) await page.wait_for_timeout(7200) await page.screenshot(path=SHOT + 'shot-phone-dark-done.png', full_page=True) await page.evaluate("window.__samon.enterPractice()") await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})") box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()") L, T, W, H = box for yy in (0.25, 0.42, 0.59, 0.76): await page.mouse.move(L + W * 0.06, T + H * yy); await page.mouse.down() await page.mouse.move(L + W * 0.94, T + H * (yy + 0.04), steps=40); await page.mouse.up() await page.screenshot(path=SHOT + 'shot-phone-dark-practice.png', full_page=True) await ctx.close() # 7b. the grey seal: help beyond the abbot's first observation marks the raking (fresh profile) ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm') ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares') async def rake_intended(i): await drag(page, G[i]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60) return await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]") async def pill_class(name): return await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(name)}).className") await page.evaluate(f"window.__samon.load({ni})") await page.click('#hint') # the authored observation only: free r1 = await rake_intended(ni) expect(r1[0] == 'grid' and not r1[1] and 'grey' not in r1[2], 'the first observation alone leaves the seal vermilion') await page.evaluate(f"window.__samon.load({ci})") await page.click('#hint'); await page.click('#hint') # observation, then a verdict: help st = await state(page) expect('grey seal' in st['status'], 'the first real help says the seal will be grey') await page.click('#restart') # starting over does not undo help r2 = await rake_intended(ci) expect(r2[0] == 'grid' and r2[1] and 'grey' in r2[2], 'a helped raking at par earns the grey seal, even after Start over') expect('grey' in await pill_class('Calm'), 'the garden list shows a grey seal') await page.click('#restart') r3 = await rake_intended(ci) expect(r3[0] == 'grid' and not r3[1] and 'grey' not in r3[2], 'raking it again unaided earns the vermilion seal') expect('sealed' in await pill_class('Calm'), 'the garden list now shows the vermilion seal') sand0 = await page.evaluate("window.__samon.sand()") await page.click('#restart') sand1 = await page.evaluate("window.__samon.sand()") expect(abs(sand0 - sand1) > 1, 'Start over smooths the raked sand back to unraked') # help travels with the route it helped: stepping back into a helped raking and restoring it re-rakes it grey ti = next(i for i, g in enumerate(G) if g['name'] == 'The pocket') await page.evaluate(f"window.__samon.load({ti})") await page.click('#hint'); await page.click('#hint') r4 = await rake_intended(ti) await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60) h = await page.evaluate("window.__samon.helped") async def rerake(): # Restore rakes again after a beat; Enter (on the garden) skips it await page.click('#restore') await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) if await page.evaluate("window.__samon.mode") == 'reveal': await page.focus('#garden'); await page.keyboard.press('Enter') await page.wait_for_function("window.__samon.mode === 'done'", timeout=3000) await rerake() r5 = await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]") expect(r4[1] and h and r5[0] == 'grid' and r5[1] and 'grey' in r5[2], f'Step back, then Restore, re-rakes a helped raking with the grey seal {r5}') tp = await centers(page, [G[ti]['intended'][2]]); await page.wait_for_timeout(700); await page.mouse.click(*tp[0]) await page.wait_for_function("window.__samon.mode === 'draw'", timeout=3000) await rerake() r6 = await page.evaluate("document.getElementById('seal').classList.contains('grey')") expect(r6, 'so does tapping back into it, then Restore') # an answer the abbot can't give in time costs nothing await page.evaluate(f"window.__samon.load({ci})") found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30); const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", found) h0 = await page.evaluate("window.__samon.helped") await page.evaluate("window.__samon.budget = 1"); await page.click('#hint') st = await state(page); h1 = await page.evaluate("window.__samon.helped") await page.evaluate("window.__samon.budget = 250000") expect(not h0 and "can't be sure" in st['status'] and 'costs you nothing' in st['status'] and not h1, f"an inconclusive answer costs nothing ({st['status']!r})") # a walk that has stopped: why is free, where to step back to is help dead = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 999); if (w.length < window.__samon.G.nSand && w.length > 3) return w; }} return null; }})()""") await page.evaluate("(w) => window.__samon.setPath(w)", dead); await page.click('#hint') st = await state(page); h2 = await page.evaluate("window.__samon.helped") expect('Saying why a walk has stopped is free' in st['status'] and not h2 and st['actions'] == ['Where can I step back to?'], f"a stopped walk: the abbot says why for free ({st['status']!r}, {st['actions']})") await page.click('#actions button >> nth=0') st = await state(page); h3 = await page.evaluate("window.__samon.helped") expect(h3 and 'to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), 'asking where to step back to is help') await ctx.close() # 7c. worlds open as the world before them is raked (two-thirds of it, at any turn count); the prototype can open all ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) w1 = [i for i, g in enumerate(G) if g['world'] == 1] w1m = [i for i in w1 if not G[i]['extra']]; need = -(-2 * len(w1m) // 3) nx = await page.evaluate("[document.querySelectorAll('#nav .pip.extra').length, document.querySelectorAll('#nav .more').length]") expect(nx[0] == sum(g['extra'] for g in G) and nx[1] == len({g['world'] for g in G if g['extra']}), f"the extras are listed after each world's main path, marked as more ({nx})") locked = await page.evaluate("[...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length") note = await page.evaluate("document.querySelector('#nav .lock')?.textContent || ''") expect(locked == len(G) - len(w1) and f'{need} gardens of Dawn' in note, f'at first only Dawn is open ({locked} locked; {note!r})') await page.click('#openAll') locked2 = await page.evaluate("[...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length") await page.click('#openAll') locked3 = await page.evaluate("[...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length") expect(locked2 == 0 and locked3 == locked, 'the prototype switch opens every world, and locks them again') for k, i in enumerate(w1m[:need]): await page.evaluate(f"window.__samon.load({i})"); await page.click('#restart') await drag(page, G[i]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60) if k == need - 2: still = await page.evaluate("window.__samon.worldOpen(2)") st = await state(page) open2 = await page.evaluate("[window.__samon.worldOpen(2), window.__samon.worldOpen(3), [...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length]") expect(not still and open2[0] and not open2[1] and 'Morning is open' in st['status'] and open2[2] == locked - len([g for g in G if g['world'] == 2]), f'raking {need} of the {len(w1m)} Dawn gardens on the main path opens Morning, and says so {open2}') # Next follows the main path: from the last Dawn garden on it, past the extras, to the first of Morning; that # garden introduces a rule, and its demonstration plays by itself last1 = w1m[-1] await page.evaluate(f"window.__samon.load({last1})"); await page.click('#restart') await drag(page, G[last1]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[last1]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100) await page.click('#next'); await page.wait_for_timeout(800) nm = await page.evaluate("window.__samon.G.name") first2 = next(g for g in G if g['world'] == 2 and not g['extra']) expect(nm == first2['name'], f'Next from the end of the main path skips the extras ({nm})') if not first2['demo']: i2 = G.index(first2) await drag(page, first2['intended']); await page.wait_for_timeout(350) pts = await centers(page, [first2['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100) await page.click('#next'); await page.wait_for_timeout(800) nm2 = await page.evaluate("window.__samon.G.name") dg = next(g for g in G if g['name'] == nm2) await page.wait_for_timeout(700) expect(dg['demo'] and await page.evaluate("window.__samon.demoRunning"), f'the garden that brings in the ripple stone demonstrates itself ({nm2})') await page.mouse.click(*(await centers(page, [dg['intended'][0]]))[0]); await page.wait_for_timeout(100) # the abbot's observation points at its cells, and they stay marked while the walk goes on pk = next(i for i, g in enumerate(G) if g['name'] == 'The pocket') await page.evaluate(f"window.__samon.load({pk})"); await page.click('#restart'); await page.click('#hint') st = await state(page); ob = await page.evaluate("window.__samon.obs.length") await drag(page, G[pk]['intended'][:3]); ob2 = await page.evaluate("window.__samon.obs.length") expect('He points' in st['status'] and ob == 1 and ob2 == 1, 'the observation points at its cell, which stays marked while drawing') await ctx.close() # 8. desktop: the pebbles garden mid-route ctx = await b.new_context(viewport={'width': 1200, 'height': 1000}, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) await page.evaluate(f"window.__samon.load({pi})"); await drag(page, G[pi]['intended'][:15]) await page.screenshot(path=SHOT + 'shot-desktop-light.png', full_page=True) await ctx.close() # 9. properties, over every garden ctx = await b.new_context(viewport={'width': 390, 'height': 900}, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page) bad_bends = [] for i, g in enumerate(G): await page.evaluate(f"window.__samon.load({i})") arcs, turns = await page.evaluate("(c) => window.__samon.strokeBends(c)", g['intended']) if not (arcs == turns == g['par']): bad_bends.append((g['name'], arcs, turns, g['par'])) nside = sum(1 for g in G if g['end']) expect(not bad_bends, f'every garden: the bends drawn in the raked stroke = the turns counted = par ({nside} with a side gate) {bad_bends[:5]}') piv = await page.evaluate("""(n) => { let pairs = 0, bad = 0; for (let i = 0; i < n; i++) { window.__samon.load(i); const G = window.__samon.G; if (!G.wide) continue; for (const v of G.sandList) for (let d = 0; d < 4; d++) { const r = Math.floor(v / G.W) + [-1, 0, 1, 0][d], c = v % G.W + [0, 1, 0, -1][d]; if (r >= 0 && r < G.H && c >= 0 && c < G.W && G.rows[r][c] === 'R') { pairs++; if (G.piv[v * 4 + d]) bad++; } } } return [pairs, bad]; }""", len(G)) expect(piv[0] > 0 and piv[1] == 0, f'under the wide rake, finished sand is never a pivot ({piv[0]} cell sides checked)') wrong, seen = [], 0 quick = bool(os.environ.get('SAMON_QUICK')) # the abbot's explanations: 400 random routes per garden in full, a sample when quick for i, g in enumerate(G): if not (g['end'] or g['wide'] or g.get('marks')): continue res = await page.evaluate(f"""(() => {{ const out = []; for (let s = 1; s < {30 if quick else 400}; s++) {{ const w = window.__samon.randomWalk({i}, s, 999); const a = window.__samon.analyzeFor({i}, w); if (a.state === 'stuck' && a.why && /only one way in, so the walk would have to end there/.test(a.why.text)) out.push([a.why.text, a.why.cells[0]]); }} return out; }})()""") W_ = len(g['rows'][0]); end = g['end'][0] * W_ + g['end'][1] if g['end'] else -1 marks = {int(k.split(',')[0]) * W_ + int(k.split(',')[1]) for k in (g.get('marks') or {})} for text, cell in res: seen += 1 want = 'side gate' if end >= 0 and cell != end else 'pebble' if cell in marks else 'wide rake' if want not in text: wrong.append((g['name'], text)) expect(seen > 0 and not wrong, f'a dead-end cell is explained by the rule that actually fails ({seen} explanations checked) {wrong[:3]}') await ctx.close() # 10. the poem is the reward: after any raking (here off par, and with the abbot's help) a poem is laid over the # garden, and then the next garden fades in; a tap on the poem goes on, a tap beside it stays; 'original' shows the Japanese; Enter goes on at once; a # demonstration earns no poem; the practice garden has one PO = json.load(open('/home/claude/zen/poems/samon-poems.json')) ppoem = lambda name: PO['poems'][PO['gardens'][name]] ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) pc = await page.evaluate("window.__samon.poems()") expect(pc['gardens'] == len(G) and pc['practice'], f'every garden has a poem, and so does the practice garden {pc}') await quiet(page, delay=200, hold=1500, fade=300) ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares') gx = garden_of(G[ni]) sub = next(s_['draw'] for s_ in gx.solve(mode=0, cap=100, stream=True)['sols'] if turns_of(gx, s_['draw']) > G[ni]['par']) await page.evaluate(f"window.__samon.load({ni})") await page.click('#hint'); await page.click('#hint') await drag(page, [list(c) for c in sub]); await page.wait_for_timeout(350) pts = await centers(page, [list(sub[0])]); await page.mouse.click(*pts[0]) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(400) po = await page.evaluate("window.__samon.poem") expect(po['visible'] and po['verse'] == ppoem('Nine squares')['translation'], f"a raking off par, made with help, still earns its poem ({po['verse'][:40]!r})") nxt = G[ni + 1]['name'] await page.wait_for_function("window.__samon.poem.leaving", timeout=4000) await page.wait_for_function(f"window.__samon.G.name === {json.dumps(nxt)}", timeout=3000) st = await state(page); po = await page.evaluate("window.__samon.poem") expect(st['mode'] == 'draw' and po['state'] == 'none' and not po['visible'], f'then garden and poem fade, and the next garden fades in ({nxt})') i2 = ni + 1 await quiet(page, delay=200, hold=1200, fade=300) await drag(page, G[i2]['intended']); await page.wait_for_timeout(350) pts = await centers(page, [G[i2]['intended'][0]]); await page.mouse.click(*pts[0]) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(300) beside = await page.evaluate("(() => { const r = document.getElementById('poem').getBoundingClientRect(), c = document.querySelector('#poem .card').getBoundingClientRect(); return [r.left + 3, c.top > r.top + 6 ? r.top + 3 : r.top + r.height - 3]; })()") await page.mouse.click(*beside); await page.wait_for_timeout(2000) st = await state(page); po = await page.evaluate("window.__samon.poem"); nm = await page.evaluate("window.__samon.G.name") expect(nm == G[i2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Staying' in st['status'] and 'Read the poem again' in st['actions'], f"a tap beside the poem puts it away and stays with the garden ({st['status'][-60:]!r})") await page.click('#actions button:has-text("Read the poem again")'); await page.wait_for_timeout(1800) po = await page.evaluate("window.__samon.poem") expect(po['visible'], 'the poem can be read again') await page.click('#orig'); await page.wait_for_timeout(100) po = await page.evaluate("window.__samon.poem") expect(po['ja'] == ppoem(G[i2]['name'])['japanese'] and not po['clash'], "'original' shows the Japanese (and no line runs under the seal)") await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(800) nm = await page.evaluate("window.__samon.G.name") expect(nm == G[i2 + 1]['name'], 'Enter goes on to the next garden at once') await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 1]['intended']) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(200) card = await page.evaluate("(() => { const c = document.querySelector('#poem .card').getBoundingClientRect(); return [c.left + c.width / 2, c.top + c.height / 2]; })()") await page.mouse.click(*card); await page.wait_for_timeout(900) nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem") expect(nm == G[i2 + 2]['name'] and po['state'] == 'none', f'a tap on the poem itself goes on to the next garden at once ({nm!r})') await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 2]['intended']) await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(700) # (a tap within 0.6 s of the raking is taken as a skip of it) wall = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + 4, r.top + 4]; })()") await page.mouse.click(*wall); await page.wait_for_timeout(300) nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem"); st = await state(page) expect(nm == G[i2 + 2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Read the poem again' in st['actions'], f'a tap on the walls around the poem puts it away and stays ({nm!r}, {po["state"]})') fit = await page.evaluate(f"""(() => {{ const out = [], fs = [], moved = [0, 0]; for (let i = 0; i < {len(G)}; i++) for (const o of [false, true]) {{ const r = window.__samon.poemFit(i, o); fs.push(r.fs); moved[0] += r.left; moved[1] += r.under; if (r.over || (r.clash && !r.under) || (r.under && !o)) out.push([i, o, r]); }} return [out, Math.min(...fs), moved]; }})()""") expect(not fit[0], f'on a phone, every poem fits its garden and clears the seal (set flush left {fit[2][0]} times, over a faded seal {fit[2][1]} times, ' f'only with the original; smallest type {fit[1]:.1f}px) {fit[0][:3]}') await page.evaluate("window.__samon.load(0)"); await page.evaluate("window.__samon.startDemo()") await page.wait_for_function("document.getElementById('status').textContent.startsWith('[That is')", timeout=15000) po = await page.evaluate("window.__samon.poem") expect(po['state'] == 'none' and not po['visible'], 'a demonstration earns no poem') await page.evaluate("window.__samon.enterPractice()") await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})") pp = await page.evaluate("document.getElementById('ppoem').textContent") expect(PO['poems'][PO['practice']]['translation'].split('\n')[0] in pp, 'the practice garden has its poem') await ctx.close() # 11. the scroll of poems earned, and the daily garden (one for everyone, picked by the date; raked once, by Rake) DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json')) ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200) await quiet(page, delay=200, hold=3600000) await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150) sc0 = await page.evaluate("[document.querySelectorAll('#scroll .entry').length, getComputedStyle(document.getElementById('scroll')).display !== 'none', getComputedStyle(document.getElementById('stage')).display, window.__samon.view]") expect(sc0 == [1, True, 'none', 'poems'], f'the scroll before any raking: only the practice poem, shown in place of the garden {sc0}') for i in (0, 1): await page.evaluate(f"window.__samon.load({i})") await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter') await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150) sc = await page.evaluate("[[...document.querySelectorAll('#scroll .entry .verse')].map(e => e.textContent), [...document.querySelectorAll('#scroll .entry .ja')].map(e => e.textContent), [...document.querySelectorAll('#scroll .left')].map(e => e.textContent)]") expect(sc[0][:2] == [ppoem(G[0]['name'])['translation'], ppoem(G[1]['name'])['translation']] and sc[1][:2] == [ppoem(G[0]['name'])['japanese'], ppoem(G[1]['name'])['japanese']] and len(sc[0]) == 3, f'two gardens raked: their poems, in order, with the Japanese, then the practice poem ({len(sc[0])})') expect(sc[2][0].startswith('[2 of') and sc[2][1] == f"[{sum(1 for g in G if g['world'] == 1) - 2} more in the gardens of Dawn.]", f'the scroll counts what is earned and what is left, in editorial brackets {sc[2][:2]}') pills = await page.evaluate("[...document.querySelectorAll('#nav .pill')].map(b => b.textContent)") expect(any(t.startswith('Poems · 2 of') for t in pills) and pills[0].startswith("Today's garden") and any(t in ('Sound on', 'Sound off') for t in pills), f'the nav offers the poems, the daily garden and the sound {pills}') await page.click('#scroll .entry .by button >> nth=1'); await page.wait_for_timeout(150) v = await page.evaluate("[window.__samon.view, window.__samon.G.name]") expect(v == ['puzzle', G[1]['name']], f"a garden's name in the scroll opens it {v}") expect(len(DAILY) == 366 and all(e['world'] == 0 and e['intended'] and e['par_moves'] for e in DAILY), f'a year of daily gardens is embedded ({len(DAILY)})') await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") d = await page.evaluate("window.__samon.daily") expect(d['year'] == len(DAILY) and d['key'] == '2026-10-04' and d['dayNo'] == 276 and not d['done'], f'the daily slot: day 276 of the year, not yet raked {d}') await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(250) g = await page.evaluate("window.__samon.G") day = DAILY[276 % len(DAILY)] hdr = await page.evaluate("[document.getElementById('time').textContent, document.getElementById('gname').textContent, document.getElementById('next').textContent, document.getElementById('rakeDay').hidden]") expect(g['rows'] == day['rows'] and hdr == ['Daily garden', '4 October 2026', 'Back to the gardens', True], f'today\'s garden is the year\'s day 276, under its date {hdr}') await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(900) st = await page.evaluate("[window.__samon.mode, document.getElementById('rakeDay').hidden, document.getElementById('status').textContent]") expect(st[0] == 'draw' and not st[1] and 'press Rake' in st[2], f'a complete walk is not raked by itself here: it waits for Rake {st}') await page.click('#rakeDay') await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000) await page.wait_for_function("window.__samon.poem.visible", timeout=5000) d2 = await page.evaluate("window.__samon.daily") st2 = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.poem.state, document.getElementById('seal').hidden]") expect(d2['done'] and d2['streak'] == 1 and d2['turns'] == day['par'] and d2['kind'] == 'red' and 'Streak: 1 day' in st2[0] and st2[1] == ["Share today's score", 'Read the poem again'] and st2[2] == 'stay' and not st2[3], f'raked at par: the record, a streak of one, the seal, the share action; the poem stays {d2} {st2}') box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()") await page.mouse.click(*box); await page.wait_for_timeout(250); await page.mouse.click(*box); await page.wait_for_timeout(250) st3 = await page.evaluate("[window.__samon.mode, document.getElementById('undo').disabled, document.getElementById('restart').disabled]") expect(st3 == ['done', True, True], f'the raking is final: no walking back, Step back and Start over off {st3}') await page.reload(); await page.wait_for_timeout(400) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300) st4 = await page.evaluate("[window.__samon.mode, document.getElementById('status').textContent, document.getElementById('seal').hidden, window.__samon.daily.streak, window.__samon.path.length]") expect(st4[0] == 'done' and "Today's record" in st4[1] and not st4[2] and st4[3] == 1 and st4[4] == len(day['intended']), f'after a reload the day\'s raking and its record are back {st4[:4]}') await page.evaluate("window.__samon.today = '2026-10-05T12:00:00'") await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300) d3 = await page.evaluate("window.__samon.daily"); g3 = await page.evaluate("window.__samon.G.rows") expect(d3['key'] == '2026-10-05' and not d3['done'] and d3['streak'] == 1 and g3 == DAILY[277 % len(DAILY)]['rows'] and await page.evaluate("window.__samon.mode") == 'draw', f'the next day is a fresh garden, and the streak counts yesterday {d3}') nx = await page.evaluate("(() => { window.__samon.load(0); document.getElementById('next').click(); return window.__samon.G.name; })()") expect(nx != d3['name'] and nx == G[1]['name'], f'the daily garden is never the next garden ({nx})') await ctx.close() # 12. drag intent: the walk follows the axis the hand moves on, not the cell under the pointer; the grip survives the walls ctx = await b.new_context(viewport={'width': 700, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(200); await quiet(page) gi = next(i for i, g in enumerate(G) if len(g['rows']) >= 6 and len(g['rows'][0]) >= 7 and g['gate'][2] == 'S' and g['gate'][0] == len(g['rows']) - 1 and set(g['rows'][-1] + g['rows'][-2]) == {'.'} and g['rows'][-3][g['gate'][1] + 3] == '.') H, W = len(G[gi]['rows']), len(G[gi]['rows'][0]); gr, gc = G[gi]['gate'][:2] async def at(r, c, dx=0.0, dy=0.0): return await page.evaluate("""([r, c, dx, dy]) => { const cv = document.getElementById('garden'); const b = cv.getBoundingClientRect(); const s = b.width / cv.width; const g = window.__samon.geom(); return [b.left + (g.ox + (c + 0.5 + dx) * g.cs) * s, b.top + (g.oy + (r + 0.5 + dy) * g.cs) * s]; }""", [r, c, dx, dy]) async def walk(): return await page.evaluate("window.__samon.path.map(k => [Math.floor(k / window.__samon.G.W), k % window.__samon.G.W])") async def begin(): await page.evaluate(f"window.__samon.load({gi}); window.__samon.setPath([]); document.getElementById('garden').scrollIntoView({{block: 'center'}})"); await page.wait_for_timeout(50) x, y = await at(gr, gc); await page.mouse.move(x, y); await page.mouse.down() run = [[gr, c] for c in range(gc, gc + 4)] await begin(); x, y = await at(gr, gc + 3, 0, -0.9); await page.mouse.move(x, y, steps=40); await page.mouse.up() expect(await walk() == run, f'a drag along the row whose hand drifts 0.9 of a cell into the next row still draws the row ({await walk()})') await begin(); x, y = await at(gr, gc + 1, 0, 1.6); await page.mouse.move(x, y, steps=12); x, y = await at(gr, gc + 3, 0, 1.8); await page.mouse.move(x, y, steps=30); await page.mouse.up() expect(await walk() == run, f'a pointer below the wall keeps its grip and the walk follows along the row ({await walk()})') await begin(); x, y = await at(gr, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=40) for k in range(1, 13): # a U-turn: a semicircle within the last cell of the run, up into the next row a = math.pi / 2 - math.pi * k / 12 x, y = await at(gr, gc + 3, 0.1 + 0.35 * math.cos(a), -0.35 + 0.35 * math.sin(a)); await page.mouse.move(x, y) x, y = await at(gr - 1, gc); await page.mouse.move(x, y, steps=40); await page.mouse.up() expect(await walk() == run + [[gr - 1, c] for c in range(gc + 3, gc - 1, -1)], f'a U-turn drawn as an arc turns once and comes back along the next row ({await walk()})') await begin(); x, y = await at(gr, gc + 3, -0.2, 0.05); await page.mouse.move(x, y, steps=30) for k in range(1, 9): # a rounded corner from heading east to heading north a = (math.pi / 2) * k / 8 x, y = await at(gr, gc + 3, -0.2 + 0.3 * math.sin(a), 0.05 - 0.3 + 0.3 * math.cos(a)); await page.mouse.move(x, y) x, y = await at(gr - 2, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=30); await page.mouse.up() expect(await walk() == run + [[gr - 1, gc + 3], [gr - 2, gc + 3]], f'a rounded corner is a run and a turn ({await walk()})') await begin(); x, y = await at(gr, gc + 3, 0, -0.6); await page.mouse.move(x, y, steps=30); out = await walk() x, y = await at(gr, gc, -0.2, -0.6); await page.mouse.move(x, y, steps=30); await page.mouse.up() expect(out == run and await walk() == [[gr, gc]], f'dragging back along a drifted run retraces it ({out} → {await walk()})') await begin() for k in range(40): x, y = await at(gr, gc, 0.12 * math.cos(k), 0.12 * math.sin(k)); await page.mouse.move(x, y) await page.mouse.up() expect(await walk() == [[gr, gc]], f'a wobble in place takes no step ({await walk()})') await ctx.close() # 13. sound: nothing before a gesture; the synthesized sounds render; Sound off persists; the recordings carried in the # page are found, credited on the scroll with their source, put down to follow a world's bell, and play import struct PIECES = json.load(open('/home/claude/zen/out/sound_pieces.json')) ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light') page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) s0 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.tracks]") expect(s0 == [True, False, 'none', None], f'before any gesture there is no audio context, and the recordings are not looked at {s0}') await page.click('#garden'); await page.wait_for_timeout(200) s1 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.timers, window.__samon.sound.tracks.map(t => [t.title, t.src.slice(0, 22), t.dur])]") expect(s1[:2] == [True, 'running'] and s1[2] in (6, 7) and s1[3] == [[p['title'], 'data:audio/mpeg;base64', p['dur']] for p in PIECES], f'a touch starts the audio, the six ambience loops (and, for a moment, the speaker\'s redraw), and finds the recordings in the page {s1}') wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'drip', 0.3], [0.3, 'tock', 0.4], [0.7, 'tok', 0.4], [0.9, 'bell', 0.5]], 1.3)")) pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:]) def peak(t0, t1): return max(abs(v) for v in pcm[int(t0 * 44100):int(t1 * 44100)]) / 32768 pk = [round(peak(*w), 3) for w in ((0.05, 0.15), (0.3, 0.4), (0.7, 0.75), (0.9, 1.2))] expect(all(p > 0.03 for p in pk) and peak(0, 0.05) == 0, f'drip, pipe, seal and bell each render audibly, and nothing before the first {pk}') wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'scrape', 1.2, 1], [1.5, 'crunch', 0.14], [1.8, 'birds', 0.1], [3.0, 'crickets', 0.08, 0.5], [3.7, 'wind', 0.3], [3.8, 'gust', 0.4, 1]], 5)")) pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:]) pk = [round(peak(*w), 3) for w in ((0.3, 1.1), (1.5, 1.6), (1.85, 2.9), (3.0, 3.5), (4.3, 5.0))] expect(all(p > 0.015 for p in pk) and peak(1.44, 1.5) < 0.02, f'the rake\'s scrape, a footstep, a bird, a cricket and the wind each render audibly, and the scrape dies away when the rake stops {pk} (tail {peak(1.44, 1.5):.3f})') dp = await page.evaluate("[5, 6, 12, 18, 19, 20, 23, 4].map(h => window.__samon.sound.dayPart(h))") expect(dp == ['dusk', 'day', 'day', 'day', 'dusk', 'night', 'night', 'night'], f'birds from 6 to 19, crickets from 20 to 5, by the local clock {dp}') await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended']) await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=3000) sc1 = await page.evaluate("window.__samon.sound.scraping") await page.wait_for_function("window.__samon.mode === 'done'", timeout=12000); await page.wait_for_timeout(100) sc2 = await page.evaluate("window.__samon.sound.scraping") expect(sc1 and not sc2, f'the scrape runs with the raking and stops with it ({sc1}, {sc2})') await page.evaluate("window.__samon.sound.setVolume(0.3)"); await page.wait_for_timeout(300) vs = await page.evaluate("[window.__samon.sound.vol, Math.round(window.__samon.sound.master * 100) / 100, localStorage.getItem('samon-sound-vol'), document.getElementById('soundVol') && document.getElementById('soundVol').value, !!document.getElementById('hapticsToggle'), window.__samon.sound.haptics]") expect(vs[:3] == [0.3, 0.3, '0.3'] and vs[4] and vs[5], f'the volume is set, remembered and reaches the master gain; the haptics switch shows where the device can tick {vs}') await page.click('#hapticsToggle'); await page.wait_for_timeout(100) hp = await page.evaluate("[window.__samon.sound.haptics, localStorage.getItem('samon-haptics'), document.getElementById('hapticsToggle').textContent]") expect(hp == [False, '0', 'Haptics off'], f'haptics can be turned off, and that is remembered {hp}') s2 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.on]") expect(s2 == [True, 'running', True], f'the live context is untouched by the offline render {s2}') tm = await page.evaluate("(() => { const a = window.__samon.sound.timers; window.__samon.sound.piece('capstone'); return [a, window.__samon.sound.timers]; })()") expect(tm[1] == tm[0] + 1, f'after a world\'s bell a piece is put down to follow {tm}') await page.evaluate(f"window.__samon.sound.play(0, {PIECES[0]['dur'] - 12})"); await page.wait_for_function("window.__samon.sound.playing !== null", timeout=5000) await page.wait_for_timeout(4000); lv = await page.evaluate("window.__samon.sound.level()") expect(lv > 0.002, f'the recording is heard: the output meter reads {lv:.4f} while it plays (silence would mean the media source is muted)') await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150) cr = await page.evaluate("[document.getElementById('soundCredits').textContent, [...document.querySelectorAll('#soundCredits a')].map(a => [a.textContent, a.href])]") expect(f"Shakuhachi, now and then: {PIECES[0]['title']}, {PIECES[0]['player']} ({PIECES[0]['license']})." in cr[0] and cr[1] == [[PIECES[0]['title'], PIECES[0]['source']]], f'the scroll credits the recording, with its source {cr}') await page.wait_for_function("window.__samon.sound.playing === null", timeout=20000) expect(True, 'the piece plays to its end and is let go') await page.click('#soundToggle'); await page.wait_for_timeout(150) s3 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]") expect(s3 == [False, 'suspended', 0, 'Sound off', '0'], f'Sound off suspends the context, clears the loops, and is remembered {s3}') await page.reload(); await page.wait_for_timeout(300); await quiet(page); await page.click('#garden'); await page.wait_for_timeout(200) s4 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, document.getElementById('soundToggle').textContent]") expect(s4 == [False, False, 'Sound off'], f'after a reload sound stays off, and a touch makes no context {s4}') noslider = await page.evaluate("!document.getElementById('soundVol')") await page.click('#soundToggle'); await page.wait_for_timeout(200) s5 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundVol').value, window.__samon.sound.vol]") expect(s5[:2] == [True, 'running'] and s5[2] in (6, 7) and s5[3:] == ['30', 0.3] and noslider, f'Sound on starts it again, with its loops (drip, pipe, wind, gusts, birds, crickets) and the remembered volume; no slider while off {s5}') # the mute button and the M key; what the page can tell of the browser's own silence mb = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), b.title]; })()") await page.click('#mute'); await page.wait_for_timeout(120) mb2 = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), window.__samon.sound.on, window.__samon.sound.state, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]; })()") await page.keyboard.press('m'); await page.wait_for_timeout(200) mb3 = await page.evaluate("[document.getElementById('mute').className, window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers > 0]") expect(mb == ['mute', 'false', 'Mute', 'Mute (M)'] and mb2 == ['mute off', 'true', 'Unmute', False, 'suspended', 'Sound off', '0'] and mb3 == ['mute', True, 'running', True], f'the speaker in the header mutes and unmutes, as does M, in step with the list\'s switch and the saved setting {mb} {mb2} {mb3}') bl = await page.evaluate("(() => { navigator.getAutoplayPolicy = () => 'disallowed'; window.__samon.sound.renderMute(); const b = document.getElementById('mute'); const r = [window.__samon.sound.blocked, b.className, b.getAttribute('aria-label')]; delete navigator.getAutoplayPolicy; window.__samon.sound.renderMute(); return r.concat([b.className]); })()") expect(bl == [True, 'mute blocked', 'Sound is blocked by the browser', 'mute'], f'when the browser says it will not play (Firefox\'s autoplay policy), the speaker says so in red, and recovers {bl}') aud = await page.evaluate("[typeof navigator.audioSession, typeof navigator.mediaSession, !!document.getElementById('muteHint') || localStorage.getItem('samon-sound-hint') === '1']") expect(aud[2], f'the first start of sound shows a one-time word beside the speaker (audioSession: {aud[0]}, mediaSession: {aud[1]})') await ctx.close() # 14. sharing: a raking is a code in the URL's hash (the walk as a number in the mixed radix of its legal moves), shown # but not credited to whoever opens it; a player's whole progress is another code, offered for taking over; the share # text draws the raking in box-drawing characters ctx = await b.new_context(viewport={'width': 500, 'height': 900}, device_scale_factor=2, color_scheme='light', permissions=['clipboard-read', 'clipboard-write']) page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e))) await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) lens = []; bits = []; bad = [] for i in (range(len(G)) if not os.environ.get('SAMON_QUICK') else range(0, len(G), 7)): g = G[i]; await page.evaluate(f"window.__samon.load({i})") code = await page.evaluate("(w) => window.__samon.share.code(w)", g['intended']) w = await page.evaluate("(w) => window.__samon.share.walk(w)", g['intended']) back = await page.evaluate("(a) => window.__samon.share.unwalk(a[0], a[1])", [w[0], len(g['intended']) - 1]) h = await page.evaluate("(c) => { const h = window.__samon.share.read(c); return [h.i, h.grey, h.n.toString()]; }", code) if not (code and back == g['intended'] and h == [i, False, w[0]]): bad.append(g['name']) lens.append(len(code)); bits.append(w[1]) expect(not bad and max(lens) <= 18, f'every intended raking round-trips through its code: {len(lens)} gardens, {min(lens)}–{max(lens)} characters (mean {sum(lens) / len(lens):.1f}); the walks take {sum(bits) / len(bits):.1f} bits on average, against {sum(2 * (len(g["intended"]) - 1) for g in G) / len(G):.0f} at two bits a step {bad}') lay = await page.evaluate("window.__samon.share.layouts()") allh = lay['campaign'] + lay['daily'] expect(len(set(allh)) == len(allh) == len(G) + 366 and all(0 <= h < 2 ** 32 for h in allh), f'every garden, campaign or daily, has its own 32-bit layout hash ({len(set(allh))} of {len(allh)} distinct)') legal = await page.evaluate("(() => { window.__samon.load(0); return window.__samon.share.walk([[2, 0], [2, 1], [1, 1]]); })()") expect(legal is None, f'a walk that breaks a rule (onto a stone) has no code {legal}') grid = await page.evaluate("(w) => window.__samon.share.text(w, 'red')", G[0]['intended']) rows = grid.split('\n') expect(rows[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and len(rows) == len(G[0]['rows']) + 3 and all(len(r) == len(G[0]['rows'][0]) + 2 for r in rows[1:]) and rows[1] == '┏━━━━┓' and rows[2] == '┃╶──┐┃' and rows[3] == '┃███│┃' and rows[4] == '┃┌──┘┃' and rows[-1] == '┗┷━━━┛', f'the share text: a heading, then the garden in box-drawing characters, the stroke in light lines through the gate in the heavy wall, stones as blocks\n{grid}') i2 = next(i for i, g in enumerate(G) if g['end'] and g['end_side'] == 'E') await page.evaluate(f"window.__samon.load({i2})") grid2 = await page.evaluate("(w) => window.__samon.share.grid(w)", G[i2]['intended']) r2 = grid2.split('\n') expect(r2[G[i2]['end'][0] + 1].endswith('┨') and sum(1 for ch in grid2 if ch in '┨┠┯┷') == 2, f'a side gate is a second gap in the wall, where the stroke begins\n{grid2}') # #r=: the raking is shown, with its seal, but recorded for nobody; Rake it yourself (or any tap) gives the viewer the garden fresh gi = 5; g5 = G[gi] await page.evaluate(f"window.__samon.load({gi})") code = await page.evaluate("(w) => window.__samon.share.code(w)", g5['intended']) await page.evaluate("localStorage.clear()") await page.goto('about:blank'); await page.goto(URL + '#r=' + code); await page.wait_for_timeout(600); await quiet(page) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, window.__samon.turnsOf(), document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), document.getElementById('seal').hidden, document.getElementById('next').hidden, localStorage.getItem('samon-progress'), !!window.__samon.share.shared, document.getElementById('undo').disabled]") expect(st[:4] == ['done', g5['name'], len(g5['intended']), g5['par']] and st[4] == f"[A raking shared with you: {g5['par']} turns, par {g5['par']}, the abbot's seal. Rake it yourself from the gate: a tap smooths the sand.]" and st[5] == ['Rake it yourself', 'Share it on'] and not st[6] and st[7] and st[8] is None and st[9] and not st[10], f'a link to a raking opens that garden raked, with the seal, credited to nobody {st[:8]} {st[8:]}') pts = await centers(page, [g5['intended'][3]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(300) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('status').textContent, location.hash, !!window.__samon.share.shared, document.getElementById('restore').hidden, document.getElementById('seal').hidden]") expect(st == ['draw', g5['name'], 0, '[Now you: drag the monk in from the gate.]', '', False, True, True], f'a tap on the shared raking smooths it away: the garden is the viewer\'s, from the gate, with nothing to Restore, and the hash is gone {st}') await page.goto(URL + '#r=' + code); await page.wait_for_timeout(400) # (a hash change on the open page shows it too) st = await page.evaluate("[window.__samon.mode, !!window.__samon.share.shared]") await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300) st2 = await page.evaluate("[window.__samon.mode, window.__samon.path.length, !!window.__samon.share.shared, location.hash]") expect(st == ['done', True] and st2 == ['draw', 0, False, ''], f'the same by Rake it yourself, after the link is followed on the open page {st} {st2}') gcode = await page.evaluate("(w) => window.__samon.share.code(w, true)", g5['intended']) await page.goto(URL + '#r=' + gcode); await page.wait_for_timeout(400) st = await page.evaluate("[document.getElementById('seal').className, document.getElementById('status').textContent.includes('his seal in grey')]") expect(st == ['seal grey', True], f'a raking made with the abbot\'s help shows his grey seal {st}') # a daily garden's raking: of another day, the viewer is sent on to today's; of today, raked at par, it is shown and then today's is theirs to rake DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json')) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200) day = DAILY[276 % len(DAILY)] dcode = await page.evaluate("(w) => window.__samon.share.code(w)", day['intended']) await page.goto('about:blank'); await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(600); await quiet(page) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('gname').textContent, document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.daily.key]") expect(st[:3] == ['done', 'daily:2026-10-04', '4 October 2026'] and 'of the daily garden of 4 October 2026' in st[3] and "Today's garden is another" in st[3] and st[4][0] == "Today's garden" and st[5] == '2026-10-04', f'a shared raking of an earlier day\'s garden is shown under its date, and offers today\'s instead {st}') await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, location.hash, window.__samon.daily.done]") expect(st[0] == 'draw' and st[1].startswith('daily:') and st[1] != 'daily:2026-10-04' and st[2] == 0 and st[3] == '' and not st[4], f'Today\'s garden leaves it for today\'s, unraked {st}') await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(400) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('status').textContent.includes('Rake it yourself'), [...document.querySelectorAll('#actions button')].map(b => b.textContent)[0]]") await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300) st2 = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('rakeDay').hidden]") expect(st == ['done', 'daily:2026-10-04', True, 'Rake it yourself'] and st2 == ['draw', 'daily:2026-10-04', 0, True], f'the same day\'s raking, shared, is shown and then left for the viewer to rake {st} {st2}') # the share text and link: the share action after a raking, to the clipboard where there is no share sheet await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) acts = await page.evaluate("[...document.querySelectorAll('#actions button')].map(b => b.textContent)") await page.click('#actions button:has-text("Share this raking")'); await page.wait_for_timeout(400) clip = await page.evaluate("navigator.clipboard.readText()") st = await page.evaluate("document.getElementById('status').textContent") cl = clip.split('\n') expect(acts == ['Share this raking'] and cl[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and cl[1:-1] == grid.split('\n')[1:] and cl[-1].startswith(URL + '#r=') and st.startswith('[Copied: the raking drawn in lines, with a link that opens it'), f'after a raking, Share this raking copies the heading, the drawing and a link that opens the raking {cl[0]!r} {cl[-1][-30:]!r} {st!r}') await page.goto('about:blank'); await page.goto(cl[-1]); await page.wait_for_timeout(600) st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, !!window.__samon.share.shared, window.__samon.turnsOf()]") expect(st == ['done', G[0]['name'], True, G[0]['par']], f'and that link shows it {st}') # #p=: progress as a code; the link offers it, Take it over joins it with what is here (whichever is better stands) await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) for i in (0, 1, 2): await page.evaluate(f"window.__samon.load({i})"); await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200) await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(300); await page.click('#rakeDay') await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000) await page.evaluate("window.__samon.load(3)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[3]['intended'][:4]); await page.wait_for_timeout(100) await page.evaluate("window.__samon.askAbbot()"); await page.wait_for_timeout(500) # (help on the unfinished walk travels with it; the save is debounced) pcode = await page.evaluate("window.__samon.share.progress()") mine = json.loads(await page.evaluate("localStorage.getItem('samon-progress')")) expect(len(pcode) < 120 and mine[G[3]['name']].get('helped') and len(mine[G[3]['name']]['draft']) == 4, f'the whole progress is a code of {len(pcode)} characters {pcode}') await page.evaluate("localStorage.clear()"); await page.goto('about:blank') await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) await page.evaluate(f"window.__samon.load(1)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[1]['intended'][:3]); await page.wait_for_timeout(400) # (something of its own here: an unfinished walk on garden 2) await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'") await page.goto(URL + '#p=' + pcode); await page.wait_for_timeout(500) st = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), localStorage.getItem('samon-progress')]") expect(st[0] == "[This link carries a player's progress: 3 of 148 gardens raked, 3 sealed, 1 daily garden, and an unfinished walk. Take it over here? It joins what is here, and whichever is better stands.]" and st[1] == ['Take it over', 'Leave it'] and G[0]['name'] not in (st[2] or ''), f'a progress link is offered, not taken, with what it holds {st[:2]}') tt = await page.evaluate("[...document.querySelectorAll('.veranda button, .assists button, .assists .t, #nav button, #nav .more, #nav input, #actions button, #mute, #orig, .count span, #where, .brand, #seal, .facts')].filter(e => !e.title).map(e => e.id || e.className || e.textContent)") expect(not tt, f'every control, count and mark has a tooltip (gwern.net wants one wherever a hover could want a word more) {tt}') await page.click('#actions button >> nth=0'); await page.wait_for_timeout(400) got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')")) st = await page.evaluate("[document.getElementById('status').textContent, location.hash, window.__samon.G.name, window.__samon.path.length, window.__samon.helped, [...document.querySelectorAll('#nav .pip.sealed')].length, window.__samon.daily.done, window.__samon.daily.turns, window.__samon.daily.streak]") expect(all(got[G[i]['name']] == mine[G[i]['name']] for i in (0, 2)) and got[G[1]['name']]['done'] and got[G[1]['name']]['sealed'] and len(got[G[1]['name']]['draft']) == 3 and got[G[3]['name']]['draft'] == mine[G[3]['name']]['draft'] and got[G[3]['name']].get('helped') and got['daily:2026-10-04']['path'] == mine['daily:2026-10-04']['path'] and got['daily:2026-10-04']['turns'] == day['par'] and got['daily:2026-10-04']['kind'] == 'red' and st[0].startswith('[Taken over: 3 of 148 gardens raked') and st[1] == '' and st[2] == G[3]['name'] and st[3] == 4 and st[4] and st[5] == 3 and st[6] and st[7] == day['par'] and st[8] == 1, f'Take it over: the rakings, the seals, the day\'s record with its walk, and the unfinished walk with its help arrive, and the walk of its own here is kept {st}') await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300) st = await page.evaluate("[window.__samon.mode, window.__samon.path.length, document.getElementById('status').textContent.includes(\"Today's record\")]") expect(st == ['done', len(day['intended']), True], f'the carried day\'s raking shows as the record {st}') await page.click('#actions button:has-text("Share today\'s score")'); await page.wait_for_timeout(400) cl = (await page.evaluate("navigator.clipboard.readText()")).split('\n') shape = await page.evaluate("window.__samon.share.grid([])") expect(cl[0] == f"Samon · daily garden 2026-10-04 · {day['par']} turns, par {day['par']} · 静 · streak 1" and cl[1:-1] == shape.split('\n') and cl[-1] == URL + '#daily' and not any(ch in shape for ch in '─│┌┐└┘╴╵╶╷') and shape.count('░') == sum(r.count('.') for r in day['rows']), f'the daily garden shares the score and the garden\'s shape, without the stroke, and a link to today\'s garden {cl[0]!r} {cl[-1][-8:]!r}') await page.evaluate("window.__samon.load(0)"); await page.goto(URL + '#daily'); await page.wait_for_timeout(400) st = await page.evaluate("[window.__samon.G.name, location.hash]") expect(st == ['daily:2026-10-04', ''], f'#daily opens today\'s garden {st}') await page.click('#carry'); await page.wait_for_timeout(400) cl = await page.evaluate("navigator.clipboard.readText()"); st = await page.evaluate("document.getElementById('status').textContent") expect(cl.startswith(URL + '#p=') and st.startswith('[Copied a link that carries your progress'), f'Carry my progress copies such a link {cl[:60]!r} {st!r}') await page.goto('about:blank'); await page.goto(URL + '#r=AAAA'); await page.wait_for_timeout(500) st = await page.evaluate("[document.getElementById('status').textContent, document.getElementById('status').className, location.hash, window.__samon.mode]") expect(st == ["[The link's code can't be read: it was made by another edition of the game.]", 'status warn', '', 'draw'], f'a code the page can\'t read is said so, and dropped {st}') await page.goto(URL + '#p=not a code'); await page.wait_for_timeout(300) st = await page.evaluate("[document.getElementById('status').textContent, location.hash]") expect(st == ["[The link's code can't be read: it is not a Samon code.]", ''], f'so is a string that is no code at all {st}') # durability (Astra's points): a garden is named by a 32-bit layout hash, not its place, so a code survives a reordering of the # campaign and a changed garden is refused, never mistaken for the one meant B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page) await page.evaluate("window.__samon.load(5)") code5 = await page.evaluate("(w) => window.__samon.share.code(w)", G[5]['intended']) bits = ''.join(format(B64.index(ch), '06b') for ch in code5) bent = bits[:5] + ('1' if bits[5] == '0' else '0') + bits[6:] # one bit of the layout hash bent = ''.join(B64[int(bent[i:i + 6], 2)] for i in range(0, len(bent), 6)) await page.goto(URL + '#r=' + bent); await page.wait_for_timeout(400) st = await page.evaluate("[document.getElementById('status').textContent, window.__samon.mode, location.hash]") expect(st == ["[The link's code can't be read: its garden is not in this edition, or has changed since.]", 'draw', ''], f'a code whose garden hash matches no garden is refused, not shown over another garden {st}') await page.evaluate("(w) => window.__samon.setPath(w)", G[5]['intended']) await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000) await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000) pcode5 = await page.evaluate("window.__samon.share.progress()") await page.evaluate("window.__samon.share.swap(5, 9); window.__samon.load(0)") # the campaign reordered under the links await page.goto(URL + '#r=' + code5); await page.wait_for_timeout(400) st = await page.evaluate("[window.__samon.G.name, window.__samon.mode, !!window.__samon.share.shared, [...document.querySelectorAll('#nav .pip')].findIndex(b => b.getAttribute('aria-current') === 'true')]") expect(st == [G[5]['name'], 'done', True, 9], f'after the campaign is reordered, a raking link still opens the garden it was made in, now in its new place {st}') await page.evaluate("localStorage.clear()") await page.goto(URL + '#p=' + pcode5); await page.wait_for_timeout(400) await page.click('#actions button:has-text("Take it over")'); await page.wait_for_timeout(400) got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')")) expect(got.get(G[5]['name'], {}).get('sealed') and G[9]['name'] not in got, f'and carried progress lands on the garden it was earned in, not on the one now at its index {sorted(got)}') await page.evaluate("window.__samon.share.swap(5, 9)") await ctx.close() await b.close() real = [e for e in errors if 'ERR_TUNNEL' not in e and 'Failed to load resource' not in e] expect(not real, f'no page errors {real}') print('FAILS:', fails) asyncio.run(main()) The poems (poems/ ) poems/originals-brainstorm.md # Original candidates for three gardens (not in the game) Written by Claude, 2026-10-04, for the three gardens whose poems are weak and for which no better classical poem turned up. None is in the game: the design's claim is that every word a player reads is a real poem, so these wait for a decision. If one goes in, `translation_credit` should say "Original, Claude 2026", `japanese` should be empty (the 原文 button then has nothing to show; the page hides it), and the Presentation paragraph loses "all real". ## Carp (W2 Morning; 7×7 open sand, one ripple stone low on the right; extra) Current: Bashō, 蛸壺やはかなき夢を夏の月 ("Octopus traps— / fleeting dreams / under the summer moon"): a great poem, a forced pairing (octopus pots for a carp pond, night for a morning world). No classical carp poem could be found and verified: the two Shiki carp verses Astra chose could not be confirmed, and the Buson, Issa and Bashō lists checked have none. 1. Morning light— / the carp sinks, / the ring stays. **(pick)** 2. One ring widening— / the carp that made it / is somewhere else. 3. The carp rises / to nothing: / a ring for its own sake. 4. Under the lily pad / the carp keeps its circle / without moving. 5. Rain over— / the carp's one ring / crosses all the others. 6. Red under green water: / a carp, and the ring / it leaves me. 7. The carp's mouth / opens at the surface— / one O, then none. 8. The whole pond / for one ring: / the carp's morning. 9. Sun on the pond— / the carp's shadow / wider than the carp. 10. Deep down / the carp holds still; / the ring goes on without it. 11. A carp turns / under the one place / the water remembers it. 12. Fed at dawn, / the carp returns / the same ring each day. Pick: 1. Seven words; the ring outlasts what made it, which is the ripple stone's rule (eight cells raked as one ring) and the game's premise (the footprints go, the raking stays); morning, as the world is. ## Go board (W4 Afternoon; 7×7, eleven pebbles; extra) Current: Shōtetsu, 白玉かなにぞととへば萩のうへの影はこたへずふるさとの月 ("White jewels— / or what? I ask; / the light upon the bush clover / gives no answer: / moon of my old home"): obscure, and the board is only in the question. No classical poem about go stones turned up. 1. Set down one by one, / the stones already / drew the line. **(pick)** 2. Black stone, white stone: / each knows one thing / and says it. 3. Eleven stones— / the game was decided / before the first was placed. 4. On the board / every stone is a question / the line answers. 5. The board is empty / where it matters; / the stones only point. 6. Pale stones go straight; / dark stones turn; / nothing else speaks. 7. Afternoon— / the stones have no color / until the line passes. 8. Each stone / holds the line / for one move. 9. The master left / and the stones / kept playing. 10. Nobody wins here: / the line / simply closes. 11. Eleven pebbles / and one way / between them. 12. The go stones / have forgotten the game / and remember the path. Pick: 1. It is how the pebbles were made (chosen one at a time, each cutting the rakings left) and what they do (together they fix the only raking), without saying either. ## Latch (W6 Dusk; 7×7, six pebbles and a side gate; combination) Current: Rengetsu, むれ立て 人くと見しは 秋風に 尾花が袖の なびく也けり ("I thought a crowd / was coming toward me— / only the sleeves / of pampas grass / waving in the autumn wind"): a stock conceit (grass mistaken for people), tied to the garden only as "expectation at a boundary". The gate poems that would fit are all in use (Issa's moon at the gate, Bashō's tide at the gate and moon as guide, Buson's sweetfish, Buson's leaving by the gate, Ryōkan's thief). 1. Not locked, / only shut: / the side gate at dusk. **(pick)** 2. The latch lifts / from the other side— / nobody there but dusk. 3. Two gates, one latch: / the wind uses / whichever is open. 4. Evening— / a latch I never set / clicks in the wind. 5. I came in / by the small gate; / the big one was only painted. 6. The latch is cold. / Beyond it, / the same garden. 7. Dusk at the side gate: / the latch remembers / a hand. 8. Lift the latch— / the path was waiting / on the inside. 9. The latch drops / behind me; / the sand ahead unmarked. 10. Someone before me / oiled the latch: / it opens without a sound. 11. A small gate, / a small latch, / the whole garden behind it. 12. Dusk— / the latch warm / from the last hand. Pick: 1. The side gate is an entrance the player must use, not a barrier; the poem says only that, at the world's hour. poems/pass2_replacements.py — Second curation pass (2026-10-04): replace the weakest pairings with better classical poems, each checked against a #!/usr/bin/env python3 """Second curation pass (2026-10-04): replace the weakest pairings with better classical poems, each checked against a page that prints it. Applied once to poems/samon-poems.json; kept for the record (about.revisions lists every swap).""" import json PATH = '/home/claude/zen/poems/samon-poems.json' ASTRA = '/home/claude/zen/poems/astra-samon-poem-selection.json' TODAY = '2026-10-04' D = json.load(open(PATH)) A = json.load(open(ASTRA)) P = D['poems'] ITOYO = 'https://www2.yamanashi-ken.ac.jp/~itoyo/basho/' BASHO = ('松尾芭蕉', '1644–1694') CLAUDE = f'New translation by Claude, {TODAY}' def basho(pid, ja, en, collection, display, url, label, notes, number=None): return dict(id=pid, poet='Matsuo Bashō', author_japanese=BASHO[0], author_dates=BASHO[1], form='haiku', japanese=ja, translation=en, translation_credit=CLAUDE, collection=collection, poem_number=number, source_url=url, source_label=label, display_source=display, notes=notes, reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url') NEW = { 'new-basho-sweep-garden': basho('new-basho-sweep-garden', '庭掃て出ばや寺に散柳', "Let me sweep the garden\nbefore I go—\nwillow leaves fall in the temple.", 'おくのほそ道 (Oku no hosomichi), Zenshōji, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno34.htm', 'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 曾良との別れ', 'Leaving the temple where he had lodged, Bashō sweeps its garden as a guest\'s thanks; the young monks had run after ' 'him with paper and inkstone. The 1903 Zenshū prints 精舍 for 寺.'), 'new-basho-moon-sand': basho('new-basho-moon-sand', '月清し遊行のもてる砂の上', "The moon is clear\non the sand\nthe pilgrim monk carried.", 'おくのほそ道 (Oku no hosomichi), Tsuruga (Kehi shrine), journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno38.htm', 'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 敦賀', '遊行 is the Yugyō Shōnin, head of the Ji sect: the second, Ta\'a, carried sand himself to fill the shrine\'s muddy ' 'grounds, and his successors keep the rite (遊行の砂持ち). The 1903 Zenshū prints 月淸し遊行の持てる砂の上.'), 'new-basho-hikarido': basho('new-basho-hikarido', '五月雨の降のこしてや光堂', "The summer rains—\ndid they spare only this?\nThe Hall of Light.", 'おくのほそ道 (Oku no hosomichi), Hiraizumi, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno20.htm', 'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 平泉', 'The Konjikidō at Chūsonji, kept whole for five centuries while everything around it fell; 降りのこす is to leave ' 'something unrained-on.'), 'new-basho-ishiyama': basho('new-basho-ishiyama', '石山の石より白し秋の風', "Whiter\nthan the stones of Stone Mountain—\nthe autumn wind.", 'おくのほそ道 (Oku no hosomichi), Natadera, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno33.htm', 'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 那谷', 'Autumn\'s color is white in the five-phase scheme; the wind outdoes the white rocks of Nata (or of Ishiyama) in it.'), 'new-basho-one-field': basho('new-basho-one-field', '田一枚植て立去る柳かな', "One field planted—\nand I leave\nthe willow.", 'おくのほそ道 (Oku no hosomichi), the Yugyō willow, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno07.htm', 'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 遊行柳', 'Under Saigyō\'s willow; who plants the field (the women, or Bashō\'s party) is left open, as is who leaves.'), 'new-basho-hida': basho('new-basho-hida', '涼しさを飛騨の工が指図かな', "Coolness—\ndrawn to the plan\nof a Hida carpenter.", 'Letter to Sugiyama Sanpū, Genroku 7, intercalary fifth month, day 11 (1694)', 'letter of 1694', ITOYO + 'letter/sanpu1.htm', 'Yamanashi Prefectural University, Bashō database: letter to Sanpū (1)', '指図 is an architect\'s drawing or direction; the carpenters of Hida were the proverbial master builders. Bashō ' 'later preferred the variant 涼しさの指図に見ゆる住まゐかな.'), 'new-basho-sea-darkens': basho('new-basho-sea-darkens', '海くれて鴨のこゑほのかに白し', "The sea darkens;\nthe voices of the ducks\nfaintly white.", '野ざらし紀行 (Nozarashi kikō), Atsuta, winter 1684', 'Nozarashi kikō', ITOYO + 'nozarasi/nozara23.htm', 'Yamanashi Prefectural University, Bashō database: Nozarashi kikō, 海邊に日暮して', 'Headnote 海邊に日暮して. A sound given a color: the source calls it one of his finest verses.'), 'new-basho-summer-room': basho('new-basho-summer-room', '山も庭も動き入るゝや夏座敷', "Mountain and garden\ncome moving in—\nthe summer room.", '芭蕉俳句全集 (1903), 夏・夏座敷; composed 1689 at Kurobane', 'Kurobane, 1689', 'https://ja.wikisource.org/wiki/芭蕉俳句全集', 'Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), summer section', 'A greeting verse for his host\'s open summer room at Kurobane, on the Oku no hosomichi journey: the view enters the room.'), 'new-ryokan-new-pond': dict(id='new-ryokan-new-pond', poet='Ryōkan', author_japanese='良寛', author_dates='1758–1831', form='haiku', japanese='新池や蛙とびこむ音もなし', translation="The new pond—\na frog jumps in;\nnot a sound.", translation_credit=CLAUDE, collection='Ryōkan zenkushū (ed. Tanikawa Toshirō), as cited by the source', poem_number=None, source_url='https://sakuramitih31.livedoor.blog/archives/41933640.html', additional_source_url='https://idea1616.com/ryokan-haiku/', source_label='名歌鑑賞: 新池や蛙とびこむ音もなし (blog, citing 谷川敏朗『良寛全句集』)', display_source='', notes='Ryōkan\'s answer to Bashō\'s old pond: the new pond has no sound. Attribution is the traditional one; the text ' 'was checked against two secondary pages, not a critical edition.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (secondary sources)'), 'new-ryokan-thief-moon': dict(id='new-ryokan-thief-moon', poet='Ryōkan', author_japanese='良寛', author_dates='1758–1831', form='haiku', japanese='盗人に取り残されし窓の月', translation="The thief\nleft it behind:\nthe moon in the window.", translation_credit=CLAUDE, collection='Ryōkan\'s hokku; the anecdote of the thief at his hut', poem_number=None, source_url='https://nichi-zen.site/remaining-moon/', additional_source_url='https://www.jomo-news.co.jp/articles/-/5483', source_label='日常実践の禅: 盗人に取り残されし窓の月', display_source='', notes='The thief found nothing worth taking (one telling has Ryōkan roll over so the man could take his bedding). ' 'Attribution is the traditional one; checked against secondary pages.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (secondary sources)'), 'new-buson-dew-thorns': dict(id='new-buson-dew-thorns', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku', japanese='白露や茨の刺にひとつづつ', translation="White dew—\non each thorn of the bramble,\none drop.", translation_credit=CLAUDE, collection='蕪村句集 (Buson kushū), autumn', poem_number=None, source_url='https://idea1616.com/buson/', additional_source_url='https://ameblo.jp/mtada99/entry-12474301599.html', source_label='与謝蕪村の俳句 102選 (list), and a commentary page', display_source='Buson kushū', notes='Checked against two secondary pages (the museum and bonjin lists used elsewhere do not carry it).', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (secondary sources)'), 'new-buson-dry-spring': dict(id='new-buson-dry-spring', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku', japanese='柳ちり清水かれ石ところどころ', translation="Willow leaves fallen,\nthe spring run dry,\nstones here and there.", translation_credit=CLAUDE, collection='蕪村集 (as indexed by the museum)', poem_number=None, source_url='https://www2.city.kurashiki.okayama.jp/musnat/plant/bungakusakuhin/busonsyuu.htm', source_label='Kurashiki Museum of Natural History: 蕪村集の植物', display_source='', notes='An answer to Saigyō\'s roadside willow and its clear water (Stepping stone): the water gone, the stones left.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'), 'new-buson-fuji-leaves': dict(id='new-buson-fuji-leaves', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku', japanese='不二ひとつうづみ残して若葉かな', translation="Young leaves—\nthey have buried everything\nbut Fuji.", translation_credit=CLAUDE, collection='蕪村句集 (Buson kushū), summer', poem_number=None, source_url='https://bonjin-ultra.com/buson.htm', source_label='bonjin-ultra: 蕪村の俳句, summer', display_source='Buson kushū', notes='うづみ残す: to leave one thing unburied.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'), 'new-issa-snail': dict(id='new-issa-snail', poet='Kobayashi Issa', author_japanese='小林一茶', author_dates='1763–1828', form='haiku', japanese='かたつぶりそろそろ登れ富士の山', translation="Little snail,\nslowly, slowly climb—\nMount Fuji.", translation_credit=CLAUDE, collection='一茶発句集 (Issa hokkushū), Bunsei edition (1829, posthumous)', poem_number=None, source_url='https://crd.ndl.go.jp/reference/entry/index.php?page=ref_view&id=1000180048', source_label='NDL Collaborative Reference Database: the collections that carry the snail verse', display_source='Issa hokkushū (1829)', notes='Not in Issa\'s own diaries; first printed two years after his death (the Kaei edition has 不二の山). Text as the ' 'Bunsei edition prints it, per the reference record.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'), 'new-tomonori-hi33': dict(id='new-tomonori-hi33', poet='Ki no Tomonori', author_japanese='紀友則', author_dates='d. c. 905', form='waka', japanese='ひさかたの 光のどけき 春の日に しづ心なく 花の散るらむ', translation="In the tranquil light\nof a spring day\nunder the wide sky—\nwhy, with no calm in their hearts,\ndo the blossoms fall?", translation_credit=CLAUDE, collection='Ogura Hyakunin Isshu; Kokin Wakashū 84', poem_number=33, source_url='https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/33/', source_label='Bunka Net n.d., Hyakunin Isshu 33', display_source='Hyakunin isshu 33', notes='しづ心 is a calm, settled heart; the garden Calm scores exactly that.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'), 'new-manyo-1068': dict(id='new-manyo-1068', poet='Kakinomoto no Hitomaro', author_japanese='柿本人麻呂歌集', author_dates='fl. c. 680–700', form='waka', japanese='天の海に雲の波立ち月の船星の林に榜ぎ隠る見ゆ', translation="On the sea of heaven\nthe waves of cloud rise;\nthe boat of the moon\nrows into the forest of stars\nand is hidden from sight.", translation_credit=CLAUDE, collection='Man\'yōshū, book 7 (from the Hitomaro Collection)', poem_number=1068, source_url='https://www.asahi-net.or.jp/~sg2h-ymst/manyok/manyok07.html', source_label='訓読万葉集 巻7 (Yamato Uta), after Kamochi Masazumi\'s 萬葉集古義', display_source='Man\'yōshū 1068', notes='Headed 天を詠める and marked as from the Kakinomoto no Hitomaro Collection, whose poems are not all his; the ' 'source prints 榜ぎ for 漕ぎ.', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'), } # Shikishi's bamboo window, from Astra's unused candidates (its page was checked today) sk = dict(A['poems']['waka-other-11']) sk.update(author_japanese='式子内親王', author_dates='d. 1201', poem_number=256, collection='Shin Kokin Wakashū', display_source='Shinkokinshū 256', translation_credit=f'Working translation by GPT-6 Astra (2026); reviewed by Claude, {TODAY}', reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (新古256, headnote 百首の歌たてまつりし時)') NEW['waka-other-11'] = sk SWAPS = [ # garden, new poem id, why ('The corridor', 'new-basho-sweep-garden', 'The first poem of the game is the chore itself: a guest sweeps the temple garden before going on. Rengetsu\'s dream-path (footprints that vanish) was good, but this is the premise.'), ('Bamboo', 'waka-other-11', 'Rengetsu\'s young bamboo was a felicitation (count its lucky joints, a thousand years): polite and empty. Shikishi\'s bamboo rustles at a window and cuts a doze short.'), ('Calm', 'new-tomonori-hi33', 'The dust-of-the-heart swept by mountain wind is a cliché. Tomonori\'s tranquil spring light names the garden\'s score, a calm heart (しづ心), by asking why the petals lack it.'), ('Plain stone', 'new-ryokan-new-pond', 'The grass hut was a forced pairing. Ryōkan\'s new pond, where the frog makes no sound, is the garden before the ripple stone; Bashō\'s old pond follows with the stone and its ring.'), ('Tatami', 'new-basho-summer-room', 'Rengetsu\'s mat of flowers is a conceit. Bashō\'s mountain and garden move into the summer room: the garden becomes a floor to sit on.'), ('The master\'s grooves', 'new-basho-hida', 'Shōtetsu\'s "learn the shape of the sixth-month wind" is didactic and obscure in English. Bashō\'s coolness drawn to a master carpenter\'s plan is the abbot\'s grooves exactly: a drawing laid down by a better hand.'), ('The finished ring', 'new-basho-hikarido', 'Rengetsu\'s sacred rope was serviceable. The Hall of Light that the summer rains left untouched is the one thing spared when everything around it was washed over, which is what the finished ring asks of the rake.'), ('His pebbles', 'new-buson-dew-thorns', 'Eifukumon\'in\'s dew overflowing the grass is diffuse. Buson\'s one drop on each thorn is a mark per point: the pebbles.'), ('Closed circle', 'new-buson-fuji-leaves', 'Rengetsu\'s bridge of blossoms, not to be stepped on, was apt but minor. Young leaves that bury everything but Fuji leave one shape standing in a covered field, as the raking leaves the ring.'), ('Pivot', 'new-basho-ishiyama', 'Shōtetsu\'s mountain that looks nearer after rain had no hold on the garden. The wide rake can turn only against stones; Bashō\'s autumn wind, whiter than the white stones it blows across, is the stroke among its pivots.'), ('Coil', 'new-issa-snail', 'Shōtetsu\'s insect voices thinner than thread were there for atmosphere. The snail carries the coil on its back and climbs Fuji at the pace the wide rake imposes.'), ('Long rake', 'new-basho-one-field', 'Shōtetsu\'s ox at dusk was filler. One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is.'), ('Turning stone', 'new-buson-dry-spring', 'Shōtetsu\'s settling snow was fine but minor. Buson\'s dried-up spring with stones here and there is a dry landscape, the garden\'s own genre, and answers Saigyō\'s willow and clear water earlier in the campaign.'), ('Bolt', 'new-ryokan-thief-moon', 'Teika\'s tied hunting robe was fastened to the title by a pun. The thief who takes everything but the moon in the window is the poem about what a bolt cannot keep in or out.'), ('Stargazer', 'new-manyo-1068', 'Shōtetsu\'s moon in a green spring sky has no stars. The Man\'yōshū moon-boat rowing into the forest of stars is the oldest and best star poem in the language, and the ripple stone is its one boat.'), ('Moonlit sea', 'new-basho-sea-darkens', 'Shōtetsu\'s wind seen in lifted mist was abstract. Bashō\'s sea darkening while the ducks\' voices turn faintly white is a night shore heard rather than seen.'), ('Owl\'s hour', 'teika-21', 'Teika\'s looking back over everything and asking the moon to light the road ahead moves here from the capstone: a sleepless hour before dawn is where that poem happens.'), ('Silver sand', 'new-basho-moon-sand', 'The capstone of the main path now gets the one classical poem about raked temple sand: the moon clear on the sand a pilgrim monk carried. Teika\'s farewell poem, good as it was here, goes to Owl\'s hour.'), ] removed = {} for garden, pid, why in SWAPS: old = D['gardens'][garden] removed[old] = P[old] D['gardens'][garden] = pid if pid in NEW: P[pid] = NEW[pid] # pairings: the positive half only (the comparison belongs in the revision log) POS = { 'The corridor': 'The first poem of the game is the chore itself: a guest sweeps the temple garden before going on, as the player rakes each garden and moves on.', 'Bamboo': 'Bamboo leaves worried by wind at a window, cutting a doze short.', 'Calm': 'Tranquil spring light, and petals that fall without a calm heart (しづ心): the quality the garden scores, named by its absence.', 'Plain stone': 'Ryōkan\'s new pond, where the frog makes no sound: the garden before the ripple stone. Bashō\'s old pond follows, with the stone and its ring.', 'Tatami': 'Mountain and garden move into the summer room: the garden becomes a floor to sit on.', 'The master\'s grooves': 'Coolness drawn to a master carpenter\'s plan: the abbot\'s grooves are a drawing laid down by a better hand, which the rake follows.', 'The finished ring': 'The Hall of Light that the summer rains left untouched: the one thing spared when everything around it is washed over, which is what the finished ring asks of the rake.', 'His pebbles': 'One drop of dew on each thorn: a mark per point, as the abbot\'s grooves become pebbles.', 'Closed circle': 'Young leaves bury everything but Fuji: one shape left standing in a covered field, as the raking leaves the ring.', 'Pivot': 'The wide rake turns only against stones; Bashō\'s autumn wind, whiter than the white stones it blows across, is the stroke among its pivots.', 'Coil': 'The snail carries its coil on its back and climbs Fuji at the pace the wide rake imposes.', 'Long rake': 'One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is.', 'Turning stone': 'A spring run dry, willow leaves down, stones here and there: a dry landscape, the garden\'s own genre, answering Saigyō\'s willow and clear water (Stepping stone).', 'Bolt': 'The thief takes everything but the moon in the window: what a bolt cannot keep in or out.', 'Stargazer': 'The moon-boat rows into the forest of stars and is hidden: the ripple stone is its one boat on a field of sand.', 'Moonlit sea': 'The sea darkens while the ducks\' voices turn faintly white: a night shore heard rather than seen; the band is its edge.', 'Owl\'s hour': 'Sleepless before dawn, everything behind rises as images, and the moon is asked to light the road ahead.', 'Silver sand': 'The last garden of the main path: the moon clear on the sand a pilgrim monk carried, the one classical poem about temple sand hauled and tended by monks.', } for g, why in POS.items(): D['pairings'][g] = why for old in removed: if old not in D['gardens'].values() and old != D['practice']: del P[old] assert 'teika-21' in P and D['gardens']['Owl\'s hour'] == 'teika-21' used = list(D['gardens'].values()) + [D['practice']] assert len(used) == len(set(used)) == len(P) == 149, (len(used), len(set(used)), len(P)) for pid, p in P.items(): assert p['translation'] and p['japanese'] and p['reverified'].startswith('yes') and p.get('author_dates'), pid D['about']['revisions'] = [ {'date': TODAY, 'pass': 2, 'garden': g, 'from': old_id, 'from_text': removed[old_id]['japanese'], 'to': pid, 'why': why} for (g, pid, why), old_id in zip(SWAPS, [r for r in removed]) ] # (the zip above pairs swaps with removed ids in order; check it) for rec, (g, pid, why) in zip(D['about']['revisions'], SWAPS): assert rec['garden'] == g and rec['to'] == pid D['about']['selection'] += (f' Second pass, {TODAY}: 18 pairings changed for better classical poems (16 poems new to the ' 'selection, 1 from Astra\'s unused candidates, 1 moved); see revisions.') D['about']['translations'] = ('English versions are working translations, not quotations of published ones: GPT-6 Astra\'s, ' 'reviewed by Claude, with ' + str(sum('revised' in p['translation_credit'] for p in P.values())) + ' revised and ' + str(sum('New translation' in p['translation_credit'] for p in P.values())) + ' new (translation_credit).') json.dump(D, open(PATH, 'w'), ensure_ascii=False, indent=1) import collections pc = collections.Counter(P[i]['poet'] for i in used) print(len(pc), pc.most_common()) print('revised', sum('revised' in p['translation_credit'] for p in P.values()), 'new', sum('New translation' in p['translation_credit'] for p in P.values())) poems/pass3_astra_review.py — Third pass (2026-10-04): GPT-6 Astra reviewed the selection and proposed 18 changes (10 classical substitutions, #!/usr/bin/env python3 """Third pass (2026-10-04): GPT-6 Astra reviewed the selection and proposed 18 changes (10 classical substitutions, 2 originals, 6 translation repairs). Taken: 7 substitutions (one redirected to another garden) and 4 translation repairs; Shikishi's bamboo was already in. Left out: the two originals (every word the player reads stays a real poem), a second Rengetsu for Calm (Tomonori's stays), the kite for Wide blade (it already introduces the grooves), and two repairs of poems the second pass had replaced. Applied once; logged in about.revisions.""" import json PATH = '/home/claude/zen/poems/samon-poems.json' ASTRA = '/home/claude/zen/poems/astra-samon-poem-selection.json' TODAY = '2026-10-04' D = json.load(open(PATH)) A = json.load(open(ASTRA)) P = D['poems'] ASTRA_TR = f'Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, {TODAY}' CHECKED = f'yes, {TODAY}: Japanese text and attribution checked against source_url' NEW = { 'new-shiki-bridge-fish': dict(id='new-shiki-bridge-fish', poet='Masaoka Shiki', author_japanese='正岡子規', author_dates='1867–1902', composed=1895, form='haiku', japanese='橋蹈めば魚沈みけり春の水', translation="I step on the bridge—\nthe fish sink.\nSpring water.", translation_credit=f'Working translation by GPT-6 Astra (2026, review pass), revised by Claude, {TODAY}', collection='獺祭書屋俳句帖抄 (Dassai shooku haiku chō shō), spring 1895', poem_number=None, source_url='https://mukei-r.net/poem-masaoka/00-haiku-dassai1.htm', source_label='獺祭書屋俳句帖抄 (transcription), 明治28年 春', display_source='1895', notes='The source prints 沈みけり (Astra\'s draft had the fish sink "deeper"). Composed 1895; the ' 'poet died in 1902, so this is the one poem whose author outlived 1900.', reverified=CHECKED), 'new-fushimi-lotus-dew': dict(id='new-fushimi-lotus-dew', poet='Emperor Fushimi', author_japanese='伏見院', author_dates='1265–1317', form='waka', japanese='こぼれ落つる池のはちすの白露は浮葉の玉とまたなりにけり', translation="Dew spills\nfrom the pond\'s lotus leaves;\non the floating leaves\nit becomes\na jewel again.", translation_credit=f'Working translation by GPT-6 Astra (2026, review pass), revised by Claude, {TODAY}', collection='Gyokuyō Wakashū (headnote: 百首御歌の中に、蓮を)', poem_number=None, source_url='https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html', source_label='和歌歳時記: 蓮葉 (Yamato Uta)', display_source='Gyokuyōshū', notes='Lotus, not water lily; the number in the Gyokuyōshū is not given by the source.', reverified=CHECKED), 'new-tamemasa-carp-lotus': dict(id='new-tamemasa-carp-lotus', poet='Reizei Tamemasa', author_japanese='冷泉為尹', author_dates='1361–1417', form='waka', japanese='池水に藻臥しの鮒や乱るらん蓮のうき葉のゆるぎ立ちぬる', translation="Hidden in the pondweed,\nare the carp stirring?\nThe lotus leaves\nfloating on the water\nhave begun to tremble.", translation_credit=ASTRA_TR, collection='Tamemasa senshu (為尹千首), topic: pond lotus', poem_number=None, source_url='https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html', source_label='和歌歳時記: 蓮葉 (Yamato Uta)', display_source='Tamemasa senshu', notes='鮒 is the crucian carp. Astra credited the poem to Reizei Tamehide; the source gives 為尹 ' '(Tamemasa, Tamehide\'s grandson).', reverified=CHECKED), 'new-buson-dancers': dict(id='new-buson-dancers', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku', japanese='四五人に月落かかるおどりかな', translation="Four or five dancers—\nthe moon\nfalls toward them.", translation_credit=ASTRA_TR, collection='Inscribed on his painting 踊図 (Dancers; Agency for Cultural Affairs)', poem_number=None, source_url='https://www.kyuhaku.jp/exhibition/exhibition_s50.html', source_label='Kyushu National Museum: 王羲之と日本の書 (exhibit list)', display_source='on his painting 踊図', notes='The Bon dance; 月落ちかかる is the moon sinking low over the dancers.', reverified=CHECKED), 'new-basho-moon-clouds': dict(id='new-basho-moon-clouds', poet='Matsuo Bashō', author_japanese='松尾芭蕉', author_dates='1644–1694', form='haiku', japanese='雲折々人をやすむる月見かな', translation="Watching the moon—\nevery so often a cloud\nlets us rest.", translation_credit=ASTRA_TR, collection='春の日 (Haru no hi), 1686', poem_number=None, source_url='https://www2.yamanashi-ken.ac.jp/~itoyo/basho/shitibusyu/harunohi.htm', source_label='Yamanashi Prefectural University, Bashō database: 俳諧七部集, 春の日', display_source='Haru no hi (1686)', notes='', reverified=CHECKED), } # Saigyō's snipe, from Astra's unused candidates (its page was checked today: 新古362, headnote 秋、ものへまかりける道にて) sg = dict(A['poems']['waka-other-05']) sg.update(author_japanese='西行', author_dates='1118–1190', collection='Shin Kokin Wakashū', poem_number=362, display_source='Shinkokinshū 362', translation="Even one like me,\nwho has set his heart aside,\nfeels it after all—\na snipe lifts from the marsh\nat autumn dusk.", translation_credit=ASTRA_TR, reverified=CHECKED) NEW['waka-other-05'] = sg # Bashō's grass hut, back from the first pass (checked then against basho-yamadera.com) gh = { "id": "new-basho-grass-hut", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "草の戸も住替る代ぞひなの家", "translation": "Even the grass hut\npasses to new dwellers—\na house of dolls.", "collection": "Oku no hosomichi (opening hokku)", "poem_number": null, "source_url": "https://basho-yamadera.com/en/yamadera/kusanotomo", "imagery": [ "grass hut", "new occupants", "dolls", "change" ], "notes": "", "verification": "", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "translation_credit": "New translation by Claude, 2026-10-04", "display_source": "Oku no hosomichi" } gh['translation'] = "Even this grass hut\nchanges hands—\na house of dolls." gh['translation_credit'] = f'Working translation by GPT-6 Astra (2026, review pass), revised by Claude, {TODAY}' NEW['new-basho-grass-hut'] = gh SWAPS = [ ('Koi', 'new-shiki-bridge-fish', 'A step on the bridge, and the fish go down: the walk in changes the pond under it. Astra\'s choice; the whitebait only glittered.'), ('Water lily', 'new-fushimi-lotus-dew', 'Dew spills from the lotus leaf and gathers into a jewel again on the leaf below: lost, then re-formed, as the rings around the stones are. Astra\'s choice over Buson\'s two pond lilies, which only named the plants.'), ('Carp', 'new-tamemasa-carp-lotus', 'The carp are never seen; the trembling lotus leaves prove them, as the ring proves the stone. Astra found the one classical carp poem we lacked; it replaces the octopus traps, a great poem forced onto a fish garden.'), ('Two doors', 'new-basho-grass-hut', 'One hut, two households through its door: the hermit leaves, a family with its dolls arrives. Astra\'s choice over the inn-sign verse "come in this way".'), ('Threshold', 'waka-other-05', 'Renunciation that cannot keep out the snipe lifting from the marsh: a boundary crossed by feeling. Astra\'s choice over Jien\'s announcement that the true path leaves nothing to long for. With Jakuren\'s Dusk and Teika\'s Plain speaking, all three of the Three Evenings are now in the game.'), ('Lantern festival', 'new-buson-dancers', 'Four or five Bon dancers with the moon sinking onto them: the ring of the dance for the garden\'s ring, and the sky brought close. Astra\'s choice over Rengetsu\'s farewell fires, whose "how it moves me" deflated the scene.'), ('Moonrise', 'new-basho-moon-clouds', 'Moon-viewing with its rests: the clouds that interrupt the looking are kind. Astra proposed it for Stargazer, which keeps the Man\'yōshū moon-boat; it replaces Shōtetsu\'s moon over the petal storm here.'), ] REPAIRS = { # Astra's better translations of poems that stay 'teika-22': "I watch the lamp\nas the window grows light.\nNo one asks\neven whether\nI am still here.", 'waka-other-26': "If I pick one,\nit must be by guess—\nwhite chrysanthemums\nlost in\nthe first frost.", 'SHOT-021': "Even the clouds\nfalter through the dusk—\na cliffside walkway\nunder snow,\nnot a footprint.", 'waka-other-04': "My heart grows clear,\nclearer still,\nendlessly in the moonlight.\nWhat will it become\nat last?", } removed = [] for garden, pid, why in SWAPS: old = D['gardens'][garden] removed.append((old, P[old]['japanese'])) D['gardens'][garden] = pid P[pid] = NEW[pid] D['pairings'][garden] = why for pid, t in REPAIRS.items(): P[pid]['translation'] = t P[pid]['translation_credit'] = f'Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, {TODAY}' used = list(D['gardens'].values()) + [D['practice']] for old, _ in removed: if old not in used: del P[old] assert len(used) == len(set(used)) == len(P) == 149, (len(used), len(set(used)), len(P)) for pid, p in P.items(): assert p['translation'] and p['japanese'] and p['reverified'].startswith('yes') and p.get('author_dates'), pid D['about']['revisions'] += [ {'date': TODAY, 'pass': 3, 'garden': g, 'from': old, 'from_text': text, 'to': pid, 'why': why} for (g, pid, why), (old, text) in zip(SWAPS, removed)] D['about']['revisions'] += [ {'date': TODAY, 'pass': 3, 'garden': g, 'translation_repair': pid, 'why': 'Astra\'s revised translation; the poem stays.'} for pid, g in (('teika-22', 'Lantern'), ('waka-other-26', 'White stones'), ('SHOT-021', 'Hairpin'), ('waka-other-04', 'Spiral'))] D['about']['selection'] += (f' Third pass, {TODAY}: GPT-6 Astra reviewed the selection; of its 18 proposals, 7 substitutions ' '(one moved to another garden) and 4 translation repairs were taken; its two original poems were not ' '(every word the player reads is a real poem).') D['about']['translations'] = ('English versions are working translations, not quotations of published ones: GPT-6 Astra\'s, ' 'reviewed by Claude, with ' + str(sum('revised' in p['translation_credit'] for p in P.values())) + ' revised and ' + str(sum('New translation' in p['translation_credit'] for p in P.values())) + ' new (translation_credit).') D['about']['texts'] = ('Every Japanese text and attribution was re-checked on ' + TODAY + ' against a page that prints it ' '(source_url; reverified records the check). Every poem was written before 1900 (one, Shiki\'s, in 1895, ' 'by a poet who died in 1902); the originals are public domain.') json.dump(D, open(PATH, 'w'), ensure_ascii=False, indent=1) import collections pc = collections.Counter(P[i]['poet'] for i in used) print(len(pc), pc.most_common()) print('revised', sum('revised' in p['translation_credit'] for p in P.values()), 'new', sum('New translation' in p['translation_credit'] for p in P.values())) anames = {l['name']: l['poem_id'] for l in A['levels']} print('new to selection', sum(i not in A['poems'] for i in used), 'changed since Astra', sum(g in anames and anames[g] != D['gardens'][g] for g in D['gardens']), 'revisions', len(D['about']['revisions'])) poems/samon-poems.json { "about": { "title": "Samon: poems for 148 gardens and the practice garden", "selection": "Poems chosen and paired with the 136 gardens of the earlier campaign by GPT-6 Astra (2026; its file is astra-samon-poem-selection.json, with 41 unused candidates). Carried over to the 148-garden campaign, where garden names follow layouts, and curated by Claude, 2026-10-04: 13 new gardens paired (3 poems new to the selection, all by Bashō), 3 pairings changed (2 carp poems attributed to Shiki whose texts could not be confirmed replaced by verified Bashō poems; Bashō’s old pond moved to the first ripple stone). Second pass, 2026-10-04: 18 pairings changed for better classical poems (16 poems new to the selection, 1 from Astra's unused candidates, 1 moved); see revisions. Third pass, 2026-10-04: GPT-6 Astra reviewed the selection; of its 18 proposals, 7 substitutions (one moved to another garden) and 4 translation repairs were taken; its two original poems were not (every word the player reads is a real poem).", "texts": "Every Japanese text and attribution was re-checked on 2026-10-04 against a page that prints it (source_url; reverified records the check). Every poem was written before 1900 (one, Shiki's, in 1895, by a poet who died in 1902); the originals are public domain.", "translations": "English versions are working translations, not quotations of published ones: GPT-6 Astra's, reviewed by Claude, with 25 revised and 17 new (translation_credit).", "fields": "gardens: garden name → poem id; practice: poem id; pairings: garden name → why that poem; poems: the texts, sources and notes; display_source: the short source shown on the poem card.", "revisions": [ { "date": "2026-10-04", "pass": 2, "garden": "The corridor", "from": "R420", "from_text": "ふみわけて ゆけど夢ぢは あともなし 思ひねにみし 花のしら雪", "to": "new-basho-sweep-garden", "why": "The first poem of the game is the chore itself: a guest sweeps the temple garden before going on. Rengetsu's dream-path (footprints that vanish) was good, but this is the premise." }, { "date": "2026-10-04", "pass": 2, "garden": "Bamboo", "from": "R115", "from_text": "日にそひて めでたきふしや かぞふらん 千代をこめたる 宿の若竹", "to": "waka-other-11", "why": "Rengetsu's young bamboo was a felicitation (count its lucky joints, a thousand years): polite and empty. Shikishi's bamboo rustles at a window and cuts a doze short." }, { "date": "2026-10-04", "pass": 2, "garden": "Calm", "from": "R670", "from_text": "しづかなる ねざめのとこに 音づれて 心のちりを はらふやま風", "to": "new-tomonori-hi33", "why": "The dust-of-the-heart swept by mountain wind is a cliché. Tomonori's tranquil spring light names the garden's score, a calm heart (しづ心), by asking why the petals lack it." }, { "date": "2026-10-04", "pass": 2, "garden": "Plain stone", "from": "new-basho-grass-hut", "from_text": "草の戸も住替る代ぞひなの家", "to": "new-ryokan-new-pond", "why": "The grass hut was a forced pairing. Ryōkan's new pond, where the frog makes no sound, is the garden before the ripple stone; Bashō's old pond follows with the stone and its ring." }, { "date": "2026-10-04", "pass": 2, "garden": "Tatami", "from": "R121", "from_text": "しくもをし まくもをしきを 秋草の 花のむしろは 野べながら見ん", "to": "new-basho-summer-room", "why": "Rengetsu's mat of flowers is a conceit. Bashō's mountain and garden move into the summer room: the garden becomes a floor to sit on." }, { "date": "2026-10-04", "pass": 2, "garden": "The master's grooves", "from": "SHOT-009", "from_text": "学びえよこひねがはしきみな月の風のすがたを大和ことのは", "to": "new-basho-hida", "why": "Shōtetsu's \"learn the shape of the sixth-month wind\" is didactic and obscure in English. Bashō's coolness drawn to a master carpenter's plan is the abbot's grooves exactly: a drawing laid down by a better hand." }, { "date": "2026-10-04", "pass": 2, "garden": "The finished ring", "from": "R133", "from_text": "松風は 秋をしらべて 野宮の しめのうちなる すずむしのこゑ", "to": "new-basho-hikarido", "why": "Rengetsu's sacred rope was serviceable. The Hall of Light that the summer rains left untouched is the one thing spared when everything around it was washed over, which is what the finished ring asks of the rake." }, { "date": "2026-10-04", "pass": 2, "garden": "His pebbles", "from": "waka-other-16", "from_text": "うす霧のはるる朝けの庭みれば草にあまれる秋の白露", "to": "new-buson-dew-thorns", "why": "Eifukumon'in's dew overflowing the grass is diffuse. Buson's one drop on each thorn is a mark per point: the pebbles." }, { "date": "2026-10-04", "pass": 2, "garden": "Closed circle", "from": "R405", "from_text": "おりたちて わたらばたえん 山がはに かぜのかけたる 花の浮橋", "to": "new-buson-fuji-leaves", "why": "Rengetsu's bridge of blossoms, not to be stepped on, was apt but minor. Young leaves that bury everything but Fuji leave one shape standing in a covered field, as the raking leaves the ring." }, { "date": "2026-10-04", "pass": 2, "garden": "Pivot", "from": "SHOT-007", "from_text": "草も木もぬれて色こき山なれや見しより近き夕立のあと", "to": "new-basho-ishiyama", "why": "Shōtetsu's mountain that looks nearer after rain had no hold on the garden. The wide rake can turn only against stones; Bashō's autumn wind, whiter than the white stones it blows across, is the stroke among its pivots." }, { "date": "2026-10-04", "pass": 2, "garden": "Coil", "from": "SHOT-011", "from_text": "露霜にあへずかれ行く秋草の糸よりよわき虫のこゑかな", "to": "new-issa-snail", "why": "Shōtetsu's insect voices thinner than thread were there for atmosphere. The snail carries the coil on its back and climbs Fuji at the pace the wide rake imposes." }, { "date": "2026-10-04", "pass": 2, "garden": "Long rake", "from": "SHOT-003", "from_text": "夕まぐれ野がひの牛は歩みきて霞める道に逢ふ人もなし", "to": "new-basho-one-field", "why": "Shōtetsu's ox at dusk was filler. One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is." }, { "date": "2026-10-04", "pass": 2, "garden": "Turning stone", "from": "SHOT-020", "from_text": "風まぜにあらく落ちしはしづまりてこまかにつもる庭の白雪", "to": "new-buson-dry-spring", "why": "Shōtetsu's settling snow was fine but minor. Buson's dried-up spring with stones here and there is a dry landscape, the garden's own genre, and answers Saigyō's willow and clear water earlier in the campaign." }, { "date": "2026-10-04", "pass": 2, "garden": "Bolt", "from": "teika-10", "from_text": "いづくにかこよひはやどをかり衣ひもゆふぐれのみねのあらしに", "to": "new-ryokan-thief-moon", "why": "Teika's tied hunting robe was fastened to the title by a pun. The thief who takes everything but the moon in the window is the poem about what a bolt cannot keep in or out." }, { "date": "2026-10-04", "pass": 2, "garden": "Stargazer", "from": "SHOT-002", "from_text": "天の原春のみどりの色にすむ月の光はかすむともなし", "to": "new-manyo-1068", "why": "Shōtetsu's moon in a green spring sky has no stars. The Man'yōshū moon-boat rowing into the forest of stars is the oldest and best star poem in the language, and the ripple stone is its one boat." }, { "date": "2026-10-04", "pass": 2, "garden": "Moonlit sea", "from": "SHOT-014", "from_text": "にほの海の霧ふきたつる程ばかり月に見えたる秋のしほかぜ", "to": "new-basho-sea-darkens", "why": "Shōtetsu's wind seen in lifted mist was abstract. Bashō's sea darkening while the ducks' voices turn faintly white is a night shore heard rather than seen." }, { "date": "2026-10-04", "pass": 2, "garden": "Owl's hour", "from": "SHOT-017", "from_text": "まどろまでさ夜もはるかに竹のはの霜にさえたる風の音かな", "to": "teika-21", "why": "Teika's looking back over everything and asking the moon to light the road ahead moves here from the capstone: a sleepless hour before dawn is where that poem happens." }, { "date": "2026-10-04", "pass": 2, "garden": "Silver sand", "from": "teika-21", "from_text": "こしかたはみな面影にうかびきぬ行末てらせ秋の夜の月", "to": "new-basho-moon-sand", "why": "The capstone of the main path now gets the one classical poem about raked temple sand: the moon clear on the sand a pilgrim monk carried. Teika's farewell poem, good as it was here, goes to Owl's hour." }, { "date": "2026-10-04", "pass": 3, "garden": "Koi", "from": "day-basho-dawn-whitebait", "from_text": "明ぼのやしら魚しろきこと一寸", "to": "new-shiki-bridge-fish", "why": "A step on the bridge, and the fish go down: the walk in changes the pond under it. Astra's choice; the whitebait only glittered." }, { "date": "2026-10-04", "pass": 3, "garden": "Water lily", "from": "supp-buson-pond-lilies-rain", "from_text": "河骨の二もとさくや雨の中", "to": "new-fushimi-lotus-dew", "why": "Dew spills from the lotus leaf and gathers into a jewel again on the leaf below: lost, then re-formed, as the rings around the stones are. Astra's choice over Buson's two pond lilies, which only named the plants." }, { "date": "2026-10-04", "pass": 3, "garden": "Carp", "from": "new-basho-octopus-pots", "from_text": "蛸壺やはかなき夢を夏の月", "to": "new-tamemasa-carp-lotus", "why": "The carp are never seen; the trembling lotus leaves prove them, as the ring proves the stone. Astra found the one classical carp poem we lacked; it replaces the octopus traps, a great poem forced onto a fish garden." }, { "date": "2026-10-04", "pass": 3, "garden": "Two doors", "from": "hn07", "from_text": "月ぞしるべこなたへ入せ旅の宿", "to": "new-basho-grass-hut", "why": "One hut, two households through its door: the hermit leaves, a family with its dolls arrives. Astra's choice over the inn-sign verse \"come in this way\"." }, { "date": "2026-10-04", "pass": 3, "garden": "Threshold", "from": "waka-other-24", "from_text": "さとりゆくまことの道に入りぬれば恋しかるべき故郷もなし", "to": "waka-other-05", "why": "Renunciation that cannot keep out the snipe lifting from the marsh: a boundary crossed by feeling. Astra's choice over Jien's announcement that the true path leaves nothing to long for. With Jakuren's Dusk and Teika's Plain speaking, all three of the Three Evenings are now in the game." }, { "date": "2026-10-04", "pass": 3, "garden": "Lantern festival", "from": "R122", "from_text": "おくり火の ほかげしらみて かも川の ぼにの月夜ぞ あはれ也ける", "to": "new-buson-dancers", "why": "Four or five Bon dancers with the moon sinking onto them: the ring of the dance for the garden's ring, and the sky brought close. Astra's choice over Rengetsu's farewell fires, whose \"how it moves me\" deflated the scene." }, { "date": "2026-10-04", "pass": 3, "garden": "Moonrise", "from": "SHOT-006", "from_text": "桜花散りかひかくす高嶺より嵐を越えて出づる月かげ", "to": "new-basho-moon-clouds", "why": "Moon-viewing with its rests: the clouds that interrupt the looking are kind. Astra proposed it for Stargazer, which keeps the Man'yōshū moon-boat; it replaces Shōtetsu's moon over the petal storm here." }, { "date": "2026-10-04", "pass": 3, "garden": "Lantern", "translation_repair": "teika-22", "why": "Astra's revised translation; the poem stays." }, { "date": "2026-10-04", "pass": 3, "garden": "White stones", "translation_repair": "waka-other-26", "why": "Astra's revised translation; the poem stays." }, { "date": "2026-10-04", "pass": 3, "garden": "Hairpin", "translation_repair": "SHOT-021", "why": "Astra's revised translation; the poem stays." }, { "date": "2026-10-04", "pass": 3, "garden": "Spiral", "translation_repair": "waka-other-04", "why": "Astra's revised translation; the poem stays." } ] }, "gardens": { "The corridor": "new-basho-sweep-garden", "Nine squares": "day-issa-frog-mountain", "Morning dew": "day-basho-melon-dew", "The pocket": "hn24", "Two tones": "R126", "Bamboo": "waka-other-11", "Sunrise": "day-basho-plum-sunrise", "First stroke": "hn30", "Calm": "new-tomonori-hi33", "Mist": "day-basho-nameless-mountain", "First bell": "R050", "Sparrow": "day-issa-orphan-sparrow", "Cold sand": "teika-14", "Lantern": "teika-22", "The long wall": "day-buson-rain-inhabited-house", "Wind chime": "SHOT-004", "Pine shadow": "teika-19", "Heron's step": "teika-20", "Quiet": "SHOT-005", "Plain stone": "new-ryokan-new-pond", "First ripple": "day-basho-old-pond", "Koi": "new-shiki-bridge-fish", "Water lily": "new-fushimi-lotus-dew", "Ripples": "day-basho-ripples-keeping-time", "Spring": "waka-other-01", "Twin rings": "day-buson-two-houses", "Two moons": "teika-11", "Frog": "day-issa-proud-frog", "Paired wells": "R102", "Raindrop": "day-basho-rain-wasp-nest", "Bell's echo": "day-buson-bell-voice", "Turtle": "day-issa-frog-on-turtle", "Stepping stone": "waka-other-03", "Pond edge": "R111", "Drip": "waka-other-10", "Carp": "new-tamemasa-carp-lotus", "Lotus": "supp-buson-lotus-fragrance", "Still water": "day-basho-cuckoo-over-water", "Well": "day-chiyoni-morning-glory-water", "Calm water": "new-basho-kiyotaki-moon", "First wave": "day-basho-green-needles-water", "High tide": "hn04", "Sandbar": "hn17", "Waves": "teika-15", "Swell": "day-buson-spring-sea", "Ichimatsu": "hyakunin-17", "Tatami": "new-basho-summer-room", "Noon sun": "day-basho-sunlit-leaves", "Estuary": "day-basho-swift-mogami", "Island in the current": "supp-basho-hot-day-sea", "Breakers": "supp-basho-evening-wave-flowers", "Current": "R034", "Shallows": "day-buson-river-crossing", "Reef": "waka-other-28", "Low tide": "day-basho-low-tide-willow", "Crosscurrent": "SHOT-008", "Undertow": "waka-other-18", "Weave": "R112", "Lattice": "hyakunin-14", "Open ground": "waka-other-07", "First grooves": "day-buson-kite-yesterday", "His first lines": "teika-06", "Interrupted": "teika-01", "The master's grooves": "new-basho-hida", "Halfway": "waka-other-02", "The finished ring": "new-basho-hikarido", "The abbot's ring": "hn03", "His grooves": "waka-other-06", "His pebbles": "new-buson-dew-thorns", "Pale and dark": "hn11", "The abbot's pebbles": "SHOT-010", "Ripple and groove": "waka-other-12", "Pebbles in the current": "waka-other-29", "Left for you": "waka-other-30", "Plain speaking": "teika-07", "Unfinished": "SHOT-025", "Called away": "teika-12", "Old grooves": "waka-other-25", "Continuation": "SHOT-015", "Closed circle": "new-buson-fuji-leaves", "White stones": "waka-other-26", "Black stones": "hn01", "Go board": "SHOT-016", "Counting stones": "waka-other-08", "Narrow rake": "hn22", "Wide blade": "day-basho-bamboo-painting", "Pivot": "new-basho-ishiyama", "Crescent": "teika-02", "Wide rake": "teika-24", "Whorl": "waka-other-14", "Wake": "teika-04", "Dragonfly": "supp-issa-dragonfly-river", "Spiral": "waka-other-04", "Lanternlight": "teika-23", "Hairpin": "SHOT-021", "Brace": "hn09", "Long rake": "new-basho-one-field", "Turning stone": "new-buson-dry-spring", "Horseshoe": "teika-08", "Coil": "new-issa-snail", "Shell": "day-buson-rain-shells", "Evening bell": "day-basho-blossom-bells", "Swallow": "hn26", "Bat": "R104", "Owl": "supp-issa-owl-fireflies", "Ember": "teika-26", "Firefly": "hn10", "Long shadow": "waka-other-17", "Last light": "waka-other-15", "Cicada": "hn08", "One gate": "hn29", "Two gates": "hn12", "Two doors": "new-basho-grass-hut", "Gatekeeper": "teika-13", "Wicket": "hn21", "Hinge": "hn16", "Latch": "R124", "Through the wall": "R131", "Side gate": "hn15", "Back gate": "hn13", "Visitor": "R055", "Garden path": "hn02", "Dusk": "waka-other-21", "Postern": "teika-16", "Threshold": "waka-other-05", "Late arrival": "R409", "Crossing": "teika-09", "Bolt": "new-ryokan-thief-moon", "Lantern festival": "new-buson-dancers", "Moonrise": "new-basho-moon-clouds", "Dark water": "waka-other-09", "Stargazer": "new-manyo-1068", "The abbot's garden": "waka-other-13", "Moonbeam": "teika-03", "Before dawn": "day-buson-white-plum-dawn", "Silver sand": "new-basho-moon-sand", "Starfield": "teika-25", "Night heron": "SHOT-024", "Owl's hour": "teika-21", "Milky Way": "hn05", "Frost": "teika-17", "Moonlit sea": "new-basho-sea-darkens", "Midnight": "SHOT-013", "Deep night": "waka-other-23", "Night rain": "R038", "Moon pool": "hn19", "Last bell": "SHOT-018", "Small hours": "SHOT-023" }, "practice": "R040", "practice_poem_reverified": true, "pairings": { "The corridor": "The first poem of the game is the chore itself: a guest sweeps the temple garden before going on, as the player rakes each garden and moves on.", "Nine squares": "The little frog's unhurried attention suits a small garden that first rewards looking before moving.", "Morning dew": "Cool dirt under a hand is a tactile match for touching and marking a fresh surface.", "The pocket": "The pocket becomes a refuge: a small protected place within a larger field.", "Two tones": "Sunlit height and lingering darkness give the alternating tones a natural counterpart.", "Bamboo": "Bamboo leaves worried by wind at a window, cutting a doze short.", "Sunrise": "The sudden sunrise gives a compact garden a moment of opening and discovery.", "First stroke": "A finger writing in empty space mirrors the player's first deliberate drawn line.", "Calm": "Tranquil spring light, and petals that fall without a calm heart (しづ心): the quality the garden scores, named by its absence.", "Mist": "The poem lets a landscape emerge from haze without requiring the scattered stones to depict a particular mountain.", "First bell": "A bell becoming perceptible through dawn mist supplies the chapter's first clear auditory presence.", "Sparrow": "Issa's invitation to play gives this early garden an affectionate, welcoming voice.", "Cold sand": "Cold exposed ground and the lack of shelter match the austerity of the nearly empty square.", "Lantern": "A solitary lamp persisting into dawn matches the title while preserving the opening world's morning light.", "The long wall": "Smoke finding passage through a wall offers a gentle counterpoint to the enclosing boundary.", "Wind chime": "Wind becomes audible and fragrance becomes dark; the title's chime expands into a dawn soundscape.", "Pine shadow": "Pine wind, color, and flowing water merge just as the dark stone and surrounding lines become one composition.", "Heron's step": "The passing egret supplies the measured white movement evoked by the title.", "Quiet": "Quiet is made audible by something almost too small to hear: falling blossoms.", "Plain stone": "Ryōkan's new pond, where the frog makes no sound: the garden before the ripple stone. Bashō's old pond follows, with the stone and its ring.", "First ripple": "The ripple stone arrives with the most famous ripple in Japanese poetry: one frog, one sound, rings spreading on still water.", "Koi": "A step on the bridge, and the fish go down: the walk in changes the pond under it. Astra's choice; the whitebait only glittered.", "Water lily": "Dew spills from the lotus leaf and gathers into a jewel again on the leaf below: lost, then re-formed, as the rings around the stones are. Astra's choice over Buson's two pond lilies, which only named the plants.", "Ripples": "Ripples and wind share a rhythm, matching the ring's integration with the surrounding parallel lines.", "Spring": "Water finding a path through newly opened clefts is a close structural counterpart to solving a route.", "Twin rings": "Two small dwellings facing one river echo the two separate centers sharing a single garden.", "Two moons": "The same moonlight held in different sleeves turns the paired circles into a relation across distance.", "Frog": "A frog sitting stiffly in the grass; Bashō’s old-pond frog moved to First ripple.", "Paired wells": "A barely seen bridge and the water beneath it suggest the quiet connection between separated circular places.", "Raindrop": "A tiny trickle negotiating an obstruction suits the first small disturbance around a stone.", "Bell's echo": "Sound departing its source is an exact counterpart to an echo spreading from a single center.", "Turtle": "The turtle carries a small living interruption; humor lightens the chapter's circling-water imagery.", "Stepping stone": "A pause beside water gives the stepping-stone image a bodily sense of rest between movements.", "Pond edge": "Water descending around rocks and a half-hidden dwelling fit the ring placed at one edge of the bed.", "Drip": "Intermittent meltwater becomes a string of separate drops at a threshold.", "Carp": "The carp are never seen; the trembling lotus leaves prove them, as the ring proves the stone. Astra found the one classical carp poem we lacked; it replaces the octopus traps, a great poem forced onto a fish garden.", "Lotus": "A stem lifting just clear of water concentrates attention on the small center of a large open surface.", "Still water": "A voice lies across still water like one long unbroken stroke.", "Well": "The occupied well bucket introduces courtesy toward an existing beauty—a thematic counterpoint to composing the stone’s surrounding ring.", "Calm water": "A spotless stream under the moon, before the wave band arrives.", "First wave": "Bashō rewrote his Kiyotaki poem on his deathbed: the clean waves now carry scattered pine needles, as the same garden now carries a wave band. The contrasting pair is a pair of drafts.", "High tide": "The tide arrives at a gate, joining the water image to the puzzle's fixed entrance.", "Sandbar": "Water striking a heron’s legs makes the boundary between standing obstacle and moving surface tangible.", "Waves": "An unseen wind changes the river's visible pattern; the raked bands likewise make motion legible.", "Swell": "The repeated phrase lets broad, unhurried motion become the poem's form.", "Ichimatsu": "The river imagined as dyed cloth connects water-patterns with a deliberately arranged textile design.", "Tatami": "Mountain and garden move into the summer room: the garden becomes a floor to sit on.", "Noon sun": "Bright light through layered leaves suits the meeting of straight bands and a ring at midday.", "Estuary": "A river gathering rain into one rapid current matches the narrow marked channel opening into a larger bed.", "Island in the current": "The river delivers the whole hot day into the sea, giving the small marked current a destination beyond its immediate channel.", "Breakers": "The wave-crests become flowers, letting repeated parallel marks read as a patterned sea.", "Current": "The moon flows with the water, making the straight current carry an image as well as a direction.", "Shallows": "Sandals in the hand bring the shallows down to the scale of bare feet.", "Reef": "A weir of leaves turns an obstruction into pattern, matching the band and adjacent stone ridge.", "Low tide": "Retreating water reveals mud and drooping branches, a direct image of an exposed margin.", "Crosscurrent": "Wind redirects leaves and birds ahead of a shower, answering the level's competing directions.", "Undertow": "The audible stream threading hidden rock gaps gives the apparent open surface an undercurrent.", "Weave": "The loom's interrupted sound evokes the patient making behind an interlaced pattern.", "Lattice": "The deliberately tangled cloth pattern introduces emotional disorder within the garden’s rigidly ordered lattice.", "Open ground": "Wind crossing open fields at dusk: many rakings fit, before the abbot’s grooves give the ground a single path.", "First grooves": "A kite flies where yesterday’s sky was: a stroke made now in a trace left earlier, as the rake follows the abbot’s grooves.", "His first lines": "The vanished wind and its garden of seeming snow connect a maker's passing gesture to the trace left behind.", "Interrupted": "The broken dream-bridge answers the interrupted groove fragments while preserving the poem's abruptness.", "The master's grooves": "Coolness drawn to a master carpenter's plan: the abbot's grooves are a drawing laid down by a better hand, which the rake follows.", "Halfway": "Choosing a new route instead of last year's marked path suits a garden that overturns an obvious opening move.", "The finished ring": "The Hall of Light that the summer rains left untouched: the one thing spared when everything around it is washed over, which is what the finished ring asks of the rake.", "The abbot's ring": "Walking around a moonlit pond respects an existing center and turns attention into a circuit.", "His grooves": "Saigyō wishes for another hut beside his own: the abbot’s work set beside the player’s.", "His pebbles": "One drop of dew on each thorn: a mark per point, as the abbot's grooves become pebbles.", "Pale and dark": "Moon and sun occupy opposite sides of one scene, giving the two kinds of pebble a balanced visual counterpart.", "The abbot's pebbles": "Moonlight turns dew into white jewels; the pebbles likewise ask the player to read small bright marks carefully.", "Ripple and groove": "A returning scent joins a present dream to an absent past, as new raking continues existing grooves.", "Pebbles in the current": "A faint stream threading moss and rock matches the single current passing through scattered instructions.", "Left for you": "The question of what to leave behind belongs naturally beside a few marks entrusted to another person.", "Plain speaking": "The poem's stripped landscape suits a garden whose sparse marks state exactly what is needed.", "Unfinished": "A single remnant after a dream suits the few surviving fragments of an unfinished composition.", "Called away": "This is the deliberately elegiac choice: work remains after an absence. The poem mourns Teika's mother; it does not establish the game's master's fate.", "Old grooves": "The old scent persists despite uncertainty about people, matching inherited marks whose maker is absent.", "Continuation": "Generations leave the moon behind for later viewers, as a groove can be taken up by another hand.", "Closed circle": "Young leaves bury everything but Fuji: one shape left standing in a covered field, as the raking leaves the ring.", "White stones": "White flowers lost against frost make discrimination within an almost uniform surface the subject.", "Black stones": "One dark crow on a bare branch matches the economy of a small decisive dark mark.", "Go board": "The bright marks invite a question and give no spoken answer, fitting a board of silent indications.", "Counting stones": "Water's white beads become countable in moonlight, an unusually close match to reading many little stones.", "Narrow rake": "A thin firefly drifting on: the ordinary rake, one last time before the wide one.", "Wide blade": "Coolness becomes visible through drawn bamboo; the new tool likewise imposes a form that can carry a sensation.", "Pivot": "The wide rake turns only against stones; Bashō's autumn wind, whiter than the white stones it blows across, is the stroke among its pivots.", "Crescent": "The partly clouded moon echoes a broken or softened arc; the poem does not specify a crescent phase.", "Wide rake": "The sweeping wind clears an entire prospect, echoing the wide tool's large-scale revelation of order.", "Whorl": "Blossoms repeatedly return around a rock: a direct physical counterpart to the whorl's encircling run.", "Wake": "Returning wings carry the weather with them, offering an aerial counterpart to a wake left by passage.", "Dragonfly": "The small dragonfly touches a whole river with its tail, matching the disproportion between the single projection and the surrounding surface.", "Spiral": "Attention grows clearer without reaching a final limit, fitting the spiral's inward movement.", "Lanternlight": "A lamp turned away from the blossoms to wait for moonrise; it shares its lamp with the Lantern garden.", "Hairpin": "A precarious, untracked passage matches the problem of finding a difficult passage and then erasing its footprints.", "Brace": "The bending leaves make the limit of a slender form physically visible, a counterpoint to the rake's turning constraint.", "Long rake": "One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is.", "Turning stone": "A spring run dry, willow leaves down, stones here and there: a dry landscape, the garden's own genre, answering Saigyō's willow and clear water (Stepping stone).", "Horseshoe": "The moonlight laid beneath a waiting figure gives an enclosing curve the intimacy of a resting place.", "Coil": "The snail carries its coil on its back and climbs Fuji at the pace the wide rake imposes.", "Shell": "Small shells and the exact amount of rain needed to wet them fit a compact, sheltered composition.", "Evening bell": "The uncertain source of a bell makes the open garden seem to extend beyond its walls.", "Swallow": "Swift evening birds meet uncertainty about tomorrow, adding vulnerability to the clean turns.", "Bat": "The fluttering bats provide the title's motion and a rare affectionate view of a dim nocturnal creature.", "Owl": "The owl seems to call the fireflies by name, giving the large, spare garden a playful voice in the approaching dark.", "Ember": "The outward fire and inward longing give the title’s ember image an inward life.", "Firefly": "The apparent fall becomes flight in the next instant, like a route whose reversal changes the meaning of the motion.", "Long shadow": "Light dwindling on a wall is a direct partner for the long-shadow image.", "Last light": "Light disappears without a dramatic sunset, matching a quiet final interval rather than a spectacle.", "Cicada": "Stone receives sound; the sparse board lets that familiar impossible image carry the whole scene.", "One gate": "The moon at a single gate, and friends thinning as the heat ebbs: the garden before a second gate is cut.", "Two gates": "The moon overhead and a walker passing through a town and out the other side: the stroke now enters by one gate and leaves by another.", "Two doors": "One hut, two households through its door: the hermit leaves, a family with its dolls arrives. Astra's choice over the inn-sign verse \"come in this way\".", "Gatekeeper": "Snow weighing on a cedar suggests a blocked road to someone awaited, matching the guarded approach.", "Wicket": "A tiny aperture opens onto the Milky Way, enlarging the significance of the little wicket.", "Hinge": "One flame transferred to another makes a connection visible without exhausting its source.", "Latch": "An apparent group of approaching visitors becomes moving grass, leaving expectation at the boundary.", "Through the wall": "A crack in a wall admits moonlight: the poem's opening is as concrete as the level's new access.", "Side gate": "A gift left at a gate records a visit through what remains, fitting the quieter alternative entrance.", "Back gate": "Crossing one's own gate changes the role of householder into traveler, making a familiar boundary newly significant.", "Visitor": "A refused lodging becomes an unexpected welcome beneath blossoms; the visitor's destination changes meaning.", "Garden path": "An empty road at dusk gives the indirect garden path its solitude.", "Dusk": "Dusk and solitude have no single color, allowing the spare layout to retain its ambiguity.", "Postern": "Moonlight enters beneath small eaves, a modest passage suited to a side entrance.", "Threshold": "Renunciation that cannot keep out the snipe lifting from the marsh: a boundary crossed by feeling. Astra's choice over Jien's announcement that the true path leaves nothing to long for. With Jakuren's Dusk and Teika's Plain speaking, all three of the Three Evenings are now in the game.", "Late arrival": "The usually late speaker arrives first for blossoms, a light reversal of the title's expectation.", "Crossing": "Wind, sleeves, and a mountain walkway give the passage between two ends bodily exposure and distance.", "Bolt": "The thief takes everything but the moon in the window: what a bolt cannot keep in or out.", "Lantern festival": "Four or five Bon dancers with the moon sinking onto them: the ring of the dance for the garden's ring, and the sky brought close. Astra's choice over Rengetsu's farewell fires, whose \"how it moves me\" deflated the scene.", "Moonrise": "Moon-viewing with its rests: the clouds that interrupt the looking are kind. Astra proposed it for Stargazer, which keeps the Man'yōshū moon-boat; it replaces Shōtetsu's moon over the petal storm here.", "Dark water": "The moon crosses from frozen water into a sleeve, while the protected surface remains intact.", "Stargazer": "The moon-boat rows into the forest of stars and is hidden: the ripple stone is its one boat on a field of sand.", "The abbot's garden": "The grassless white-gravel courtyard is the anthology's closest literal dry-garden setting; an unseen cricket animates it.", "Moonbeam": "Moonlight entering through the eaves competes with fragrance on one sleeve, concentrating two sensations in a narrow beam.", "Before dawn": "Only the coming of day remains; the poem lets the enormous last stretch wait rather than hurry.", "Silver sand": "The last garden of the main path: the moon clear on the sand a pilgrim monk carried, the one classical poem about temple sand hauled and tended by monks.", "Starfield": "Clear stars and barely perceptible hail give the scattered pattern both distance and minute texture.", "Night heron": "A heron departs but white flowers preserve its color, matching an action remembered by its visible trace.", "Owl's hour": "Sleepless before dawn, everything behind rises as images, and the moon is asked to light the road ahead.", "Milky Way": "The Milky Way stretches over sea and island, matching the immense field around one stone.", "Frost": "Frost accumulates night after night until the intervals close, making small repeated deposits fill a large space.", "Moonlit sea": "The sea darkens while the ducks' voices turn faintly white: a night shore heard rather than seen; the band is its edge.", "Midnight": "The moon becomes inward and then is forgotten; this is the sequence's deepest absorption in a single center.", "Deep night": "A dream inside a dream gives the repeated layout another depth rather than merely another name.", "Night rain": "Rain heard during a sleepless night makes a broad dark expanse intimate and personal.", "Moon pool": "A boat holds a private moon within a vast frosty prospect; the small ring beside the endpoint likewise gives a broad night garden an intimate center.", "Last bell": "The bell seems to sound inside the moon, giving the final chime both resonance and cold clarity.", "Small hours": "A sudden stillness makes one distant break audible, stretching the smallest event across the last hours of night." }, "practice_pairing": "Hands cup and release threads of water: an idle, tactile pleasure without a required destination.", "poems": { "R034": { "id": "R034", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "とけわたる 氷にそひて はるのよの 月もながるる 井堤の玉川", "translation": "As the ice\nmelts along the stream,\nthe spring-night moon\nflows with it—\nthe Tama River at Ide.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "34", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1", "source_label": "Rengetsu Foundation n.d., poem 34", "imagery": [ "flow", "moon", "ice", "reflection" ], "notes": "Spring moon and thawing ice share a current.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 34", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R038": { "id": "R038", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "ふかき夜を 思ひねざめの はる雨は 音を聞にも ぬるる袖かな", "translation": "Deep in the night\nI wake from troubled sleep.\nThe spring rain—\nmerely to hear it\nwets my sleeves.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "38", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1", "source_label": "Rengetsu Foundation n.d., poem 38", "imagery": [ "night", "rain", "sleeves", "tears" ], "notes": "Wet sleeves link the sound of spring rain to the waking speaker’s tears.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 38", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R040": { "id": "R040", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "つれづれと 春のながめの 手すさびに むすびて流す 軒のいと水", "translation": "An idle pastime\nthrough the long spring rain:\nI cup my hands\nbeneath the threads from the eaves\nand let them run.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "40", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1", "source_label": "Rengetsu Foundation n.d., poem 40", "imagery": [ "hands", "play", "water", "threads" ], "notes": "Excellent for the unscored practice garden; nagame suggests both lingering contemplation and long rain.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 40", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R050": { "id": "R050", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "東山 花まつ比の 朝ぼらけ かすみに匂ふ かねの音かな", "translation": "At Higashiyama,\nwaiting for the blossoms—\ndaybreak;\nthrough the haze\nthe bell’s sound glows.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "50", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1", "source_label": "Rengetsu Foundation n.d., poem 50", "imagery": [ "dawn", "bell", "mist" ], "notes": "Niou crosses visual radiance into sound.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 50", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R055": { "id": "R055", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "やどかさぬ 人のつらさを 情にて おぼろ月よの 花の下ぶし", "translation": "Refused a bed,\nI take their unkindness\nfor kindness—\nbeneath blossoms I sleep\nin the hazy moonlight.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "55", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1", "source_label": "Rengetsu Foundation n.d., poem 55", "imagery": [ "gate", "refusal", "shelter", "moon" ], "notes": "A closed door opens another kind of lodging.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 55", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R102": { "id": "R102", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "夕月夜 ほのかに見ゆる 小板橋 下行水に 水鶏啼なり", "translation": "The evening moon:\na little plank bridge\nbarely visible;\nunder it, running water\nand a water rail’s cry.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "102", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3", "source_label": "Rengetsu Foundation n.d., poem 102", "imagery": [ "bridge", "moon", "water", "crossing" ], "notes": "Water rail, not heron.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 102", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R104": { "id": "R104", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "軒近き 柳になびく かはほりの かげなつかしき 薄月夜かな", "translation": "Beside the eaves\nbats flutter through the willow—\ntheir silhouettes\nhow dear to me\nin this thin moonlight.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "104", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3", "source_label": "Rengetsu Foundation n.d., poem 104", "imagery": [ "bats", "willow", "moon", "shadows" ], "notes": "A direct match to Bat.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 104", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R111": { "id": "R111", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "岩つたふ しみづすずしき 山かげの 葉がくれいほに 住人やたれ", "translation": "Cool springwater\nruns down the rocks\nin the mountain shade.\nWho lives in the hut\nhidden by leaves?", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "111", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3", "source_label": "Rengetsu Foundation n.d., poem 111", "imagery": [ "rocks", "water", "hut", "visitor" ], "notes": "The occupant is left unseen.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 111", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R112": { "id": "R112", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "さとの子が はたおる音も とだへして 昼ねの比の あつき旅哉", "translation": "Even the village children’s\nweaving has fallen silent.\nThe hour of the midday nap—\non the road,\nhow hot it is.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "112", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3", "source_label": "Rengetsu Foundation n.d., poem 112", "imagery": [ "weaving", "interruption", "noon", "silence" ], "notes": "A human interruption in a patterned sound.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 112", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R124": { "id": "R124", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "むれ立て 人くと見しは 秋風に 尾花が袖の なびく也けり", "translation": "I thought a crowd\nwas coming toward me—\nonly the sleeves\nof pampas grass\nwaving in the autumn wind.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "124", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=4", "source_label": "Rengetsu Foundation n.d., poem 124", "imagery": [ "visitor", "illusion", "grass", "wind" ], "notes": "An apparent arrival turns into vegetation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 124", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R126": { "id": "R126", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "さし登る 朝日の山も ふもとには 猶夜をのこす 宇治の河霧", "translation": "Sunlight climbs\nMount Asahi;\nbelow,\nthe river mist at Uji\nstill holds the night.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "126", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=4", "source_label": "Rengetsu Foundation n.d., poem 126", "imagery": [ "dawn", "two tones", "mist", "contrast" ], "notes": "Light and remaining darkness coexist in one landscape.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 126", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R131": { "id": "R131", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "山ざとの かべのやれ間の きりぎりす 月もここより させよとぞ鳴", "translation": "In the mountain village,\na cricket in a broken wall\ncries out:\nlet the moonlight\ncome through here too.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ama no Karumo", "poem_number": "131", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=4", "source_label": "Rengetsu Foundation n.d., poem 131", "imagery": [ "wall", "opening", "moon", "cricket" ], "notes": "Kirigirisu in older poetry commonly denotes a cricket; the source English uses grasshopper.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Ama no karumo 131", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "R409": { "id": "R409", "poet": "Ōtagaki Rengetsu", "form": "waka", "japanese": "何事も 人におくるる さがの山 花見ばかりは 先ぞきにける", "translation": "Always behind\neveryone else—\nbut at Mount Saga,\nfor the cherry blossoms,\nI have arrived first.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Rengetsu Foundation database; collected or inscribed poems", "poem_number": "database 409", "source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=13", "source_label": "Rengetsu Foundation n.d., poem 409", "imagery": [ "arrival", "late", "flowers", "humor" ], "notes": "A lightly comic counterpoint for Late arrival; Saga also plays against personal disposition.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "大田垣蓮月", "author_dates": "1791–1875" }, "SHOT-004": { "id": "SHOT-004", "poet": "Shōtetsu", "form": "waka", "japanese": "山風の松に木ぶかき音はして花の香くらき明けぼのの雨", "translation": "Deep in the pines\nthe mountain wind sounds;\nin the dawn rain,\neven the fragrance\nof blossoms is dark.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "1537", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "pines", "wind", "dawn", "rain", "blossoms", "fragrance" ], "notes": "Topic: 曙春雨 (spring rain at dawn). Preserves the synesthesia of 花の香くらき, dark blossom fragrance.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 1537", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-005": { "id": "SHOT-005", "poet": "Shōtetsu", "form": "waka", "japanese": "まぎるべき風さへ吹かで散りかかる花の音きく窓のうちかな", "translation": "Not even wind\nto hide the sound—\nat the window,\nI hear the blossoms\nfall.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "1362", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "silence", "blossoms", "window", "falling petals", "wind" ], "notes": "Topic: 閑居花 (flowers in quiet retirement). The translation treats まぎる as obscuring/masking the sound. Source notes a second-line variant in Shōtetsu senshu; the recorded Japanese follows its main text.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 1362", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-008": { "id": "SHOT-008", "poet": "Shōtetsu", "form": "waka", "japanese": "山づたひ夕立うつる風さきに木の葉も鳥もふかれてぞ行く", "translation": "Over the mountains\nthe shower shifts;\nin the wind before it,\nthe leaves and birds\nare swept away.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "2635", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "rain", "wind", "mountains", "birds", "leaves", "movement" ], "notes": "Topic: 遠夕立 (a distant shower).", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 2635", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-010": { "id": "SHOT-010", "poet": "Shōtetsu", "form": "waka", "japanese": "秋草の露とこたふる風もなしただしら玉をみがく月かげ", "translation": "Not even wind\nto answer, “Dew\non autumn grass”—\nonly the moonlight\npolishing white gems.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "3437", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "dew", "moon", "grass", "pearls", "silence", "autumn" ], "notes": "Topic: 露如玉 (dew like jewels). Alludes to the question about white jewels/dew in Ise monogatari6; wind would disperse the jeweled illusion.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 3437", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-013": { "id": "SHOT-013", "poet": "Shōtetsu", "form": "waka", "japanese": "すみのぼる心にすめる月をみて月をわするる秋のさ夜中", "translation": "My heart rises clear;\nI watch the moon\nshining within it,\nand forget the moon—\nautumn midnight.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "3990", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "moon", "mind", "meditation", "forgetting", "midnight", "autumn" ], "notes": "Topic: 閑見月 (quiet moon-viewing). すむ evokes both becoming clear and dwelling; this working translation conveys clarity and inward presence without claiming to reproduce the wordplay fully.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 3990", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-015": { "id": "SHOT-015", "poet": "Shōtetsu", "form": "waka", "japanese": "むかしよりいく世の人かあかずしてながめすてけん故郷の月", "translation": "How many generations\nsince long ago\ngazed, never satisfied,\nand left you behind—\nmoon of the old home?", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "4172", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "moon", "old home", "time", "generations", "departure" ], "notes": "Topic: 古郷月 (moon at the old home); the source reports 寄月懐旧 in Shōtetsu senshu. 故郷 can suggest an old capital or former home; the English leaves the place unspecified.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 4172", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-016": { "id": "SHOT-016", "poet": "Shōtetsu", "form": "waka", "japanese": "白玉かなにぞととへば萩のうへの影はこたへずふるさとの月", "translation": "“White jewels—\nor what?” I ask;\nthe light upon the bush clover\ngives no answer:\nmoon of my old home.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "4704", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "dew", "moon", "bush clover", "pearls", "old home", "question" ], "notes": "Topic: 萩露 (dew on bush clover). Another allusion to Ise monogatari6; distinct from3437, though their motifs overlap.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 4704", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-018": { "id": "SHOT-018", "poet": "Shōtetsu", "form": "waka", "japanese": "月のうちにひびきのぼると思ふまで霜夜の鐘に影ぞさえ行く", "translation": "Till it seems\nthe sound climbs into the moon,\nthe frosty night’s bell—\nand the moonlight\ngrows colder.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "5357", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "moon", "bell", "frost", "night", "sound", "cold" ], "notes": "Topic: 冬月 (winter moon). 冴える here permits clearer/brighter as well as colder; this version chooses colder to preserve the sensory crossing.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 5357", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-021": { "id": "SHOT-021", "poet": "Shōtetsu", "form": "waka", "japanese": "わたりかね雲も夕を猶たどる跡なき雪の嶺のかけはし", "translation": "Even the clouds\nfalter through the dusk—\na cliffside walkway\nunder snow,\nnot a footprint.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "5885", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "bridge", "snow", "footprints", "clouds", "dusk", "path" ], "notes": "Kakehashi is a precarious mountain walkway or ladder across a steep slope. The source also finds an ambiguity between luminous snow and approaching dusk; no suspension bridge is implied.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 5885", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-023": { "id": "SHOT-023", "poet": "Shōtetsu", "form": "waka", "japanese": "さ夜風はただ一足にしづまりてをち方きけば雪折の声", "translation": "The night wind\nfalls still all at once.\nFrom far away,\nas I listen,\na bough breaks under snow.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "6053", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "night", "wind", "silence", "snow", "branch", "sound" ], "notes": "Hitoashi ni is rendered all at once rather than supplying an unverified literal footstep by the wind.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 6053", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-024": { "id": "SHOT-024", "poet": "Shōtetsu", "form": "waka", "japanese": "むら雨のふる江をよそに飛ぶさぎの跡まで白きおもだかの花", "translation": "From the rain-swept inlet\na heron flies away;\neven the place it leaves\nis white—\narrowhead flowers.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "9064", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "heron", "rain", "inlet", "flowers", "whiteness", "trace" ], "notes": "Topic: 江雨鷺飛 (heron flying over rain at an inlet). おもだか is the aquatic arrowhead plant, Sagittaria; “arrowhead” is botanical, not a weapon.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 9064", "author_japanese": "正徹", "author_dates": "1381–1459" }, "SHOT-025": { "id": "SHOT-025", "poet": "Shōtetsu", "form": "waka", "japanese": "あととめてさむるか夢の中空に孤の雲の残るをぞ見る", "translation": "Does a dream\nleave this trace on waking?\nIn the open sky\nI see\na single cloud remain.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Sōkonshū (草根集)", "poem_number": "10428", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html", "source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text", "additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html", "imagery": [ "dream", "cloud", "sky", "trace", "awakening", "solitude" ], "notes": "Topic: 孤夢 (solitary dream). Source explicitly glosses 孤 as ひとり. The relation between a lingering dream and the remaining cloud is kept as a question.", "numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.", "verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sōkonshū 10428", "author_japanese": "正徹", "author_dates": "1381–1459" }, "day-basho-bamboo-painting": { "id": "day-basho-bamboo-painting", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "すヾしさを絵にうつしけり嵯峨の竹", "translation": "Coolness\nmade into a painting:\nthe bamboo of Saga.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "住吉物語 (Sumiyoshi monogatari)", "source_url": "https://www.basho.jp/hatonokai/kendaikaisetsu/kendai25_07.html", "source_label": "Bashō Kaigi, seasonal-word commentary", "imagery": [ "bamboo", "coolness", "painting", "summer", "Saga" ], "notes": "Fresh working translation. Source spelling retained; frequently printed 涼しさを絵にうつしけり嵯峨の竹.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-blossom-bells": { "id": "day-basho-blossom-bells", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "花の雲鐘は上野か浅草か", "translation": "Clouds of blossoms—\nthat bell: from Ueno\nor Asakusa?", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "続虚栗 (Zoku minashiguri), 1687", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/hanano.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "blossoms", "bell", "temples", "spring", "distance" ], "notes": "Fresh working translation. The two place names identify actual temple districts in Edo.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Zoku minashiguri (1687)", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-cuckoo-over-water": { "id": "day-basho-cuckoo-over-water", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "ほととぎす声や横たふ水の上", "translation": "A cuckoo—\nits voice lies out\nacross the water.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Letter to Miyazaki Keikō, Genroku 6, fourth month, day 29 (1693)", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/keiko.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "cuckoo", "water", "voice", "horizontal", "stillness" ], "notes": "Fresh working translation. This wording is in Bashō's letter. 藤の実 has the variant ほととぎす声横たふや水の上.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "letter of 1693", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-green-needles-water": { "id": "day-basho-green-needles-water", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "清滝や波に散り込む青松葉", "translation": "Kiyotaki—\ngreen pine needles\nscattering into the waves.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "去来抄 (Kyorai shō), wording discussed in the linked commentary; 1694 poem", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu2.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "pine", "needles", "stream", "waves", "green" ], "notes": "Fresh working translation. The source discusses this revision alongside 清滝や波に塵なき夏の月. Do not treat the two as independent poems in one anthology.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Kyoraishō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-low-tide-willow": { "id": "day-basho-low-tide-willow", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "青柳の泥にしだるる潮干かな", "translation": "Green willow branches\nhang down into the mud—\nthe tide is out.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "炭俵 (Sumidawara)", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/aoyagi.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "willow", "low tide", "mud", "green", "shore" ], "notes": "Fresh working translation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sumidawara (1694)", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-melon-dew": { "id": "day-basho-melon-dew", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "朝露や撫でて涼しき瓜の土", "translation": "Morning dew—\nthe dirt on the melon\ncool beneath my hand.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Letter to Sugiyama Sanpū, Genroku 7, sixth month, day 24 (1694)", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu2.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "morning", "dew", "melon", "soil", "touch", "coolness" ], "notes": "Fresh working translation. 土 refers to dirt adhering to the melon in the source's reading.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "letter of 1694", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-nameless-mountain": { "id": "day-basho-nameless-mountain", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "春なれや名もなき山の薄霞", "translation": "Spring has come—\na mountain without a name\nin thin haze.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "野ざらし紀行 (Nozarashi kikō), road to Nara section", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/nozarasi/nozara25.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "spring", "mountain", "haze", "mist" ], "notes": "Fresh working translation. This source prints 薄霞, thin haze, and records 朝霞, morning haze, as a variant. Do not silently mix their readings.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Nozarashi kikō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-old-pond": { "id": "day-basho-old-pond", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "古池や蛙飛びこむ水の音", "translation": "The old pond—\na frog jumps in;\nthe sound of water.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蛙合 (Kawazu awase), 1686", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/huruike.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "pond", "frog", "water", "sound", "ripples" ], "notes": "Fresh working translation of the Japanese; no English syllable constraint. The Japanese does not specify the number of frogs.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Kawazu awase (1686)", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-plum-sunrise": { "id": "day-basho-plum-sunrise", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "梅が香にのつと日の出る山路哉", "translation": "Into plum fragrance\nthe sun suddenly rises—\na mountain path.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "炭俵 (Sumidawara), opening verse, 1694", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/umegaka.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "sunrise", "plum", "fragrance", "mountain path" ], "notes": "Fresh working translation. のつと gives the rising sun an abrupt, almost casual appearance.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sumidawara (1694)", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-rain-wasp-nest": { "id": "day-basho-rain-wasp-nest", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "春雨や蜂の巣つたふ屋根の漏り", "translation": "Spring rain—\nthe roof’s leak trickles\ndown a wasp nest.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "炭俵 (Sumidawara), 1694", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/harsame3.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "spring rain", "roof", "nest", "drips", "hut" ], "notes": "Fresh working translation. 蜂 can cover bees or wasps; an exposed old nest beneath the eaves supports 'wasp nest'.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sumidawara (1694)", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-ripples-keeping-time": { "id": "day-basho-ripples-keeping-time", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "さざ波や風の薫の相拍子", "translation": "Small ripples\nkeeping time\nwith the fragrant wind.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "笈日記 (Oi nikki), 1694", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/noryo1.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "ripples", "wind", "fragrance", "lake", "rhythm" ], "notes": "Fresh working translation. 相拍子 is a musical term; the poem greeted a Noh performer.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Oi nikki", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-sunlit-leaves": { "id": "day-basho-sunlit-leaves", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "あらたうと青葉若葉の日の光", "translation": "How holy—\nsunlight through green leaves,\nthrough new leaves.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Nikkō section, journey of 1689", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno051.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "sunlight", "leaves", "green", "temple", "early summer" ], "notes": "Fresh working translation. The Nikkō setting carries religious and political praise; this is not merely a neutral nature sketch.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Oku no hosomichi", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-basho-swift-mogami": { "id": "day-basho-swift-mogami", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "五月雨をあつめて早し最上川", "translation": "Gathering\nthe summer rains, so swift—\nthe Mogami River.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Mogami River section, journey of 1689", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno24.htm", "source_label": "Yamanashi Prefectural University, Bashō database", "imagery": [ "rain", "river", "current", "summer", "gathering" ], "notes": "Fresh working translation. 五月雨 means the early-summer rainy season, not modern-calendar May rain.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Oku no hosomichi", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "day-buson-bell-voice": { "id": "day-buson-bell-voice", "poet": "Yosa Buson", "form": "haiku", "japanese": "涼しさや鐘をはなるるかねの声", "translation": "Coolness—\nthe bell’s voice\nleaves the bell.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://www.basho.jp/hatonokai/kendaikaisetsu/kendai25_07.html", "source_label": "Bashō Kaigi, seasonal-word commentary", "imagery": [ "bell", "sound", "coolness", "summer", "temple" ], "notes": "Fresh working translation. The linked poets' society page verifies text and authorship but does not name the original collection.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-kite-yesterday": { "id": "day-buson-kite-yesterday", "poet": "Yosa Buson", "form": "haiku", "japanese": "凧きのふの空のありどころ", "translation": "A kite—\nthere is the place\nwhere yesterday’s sky was.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū)", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "kite", "sky", "spring", "daylight" ], "notes": "Fresh working translation. Backup. The museum displays the kite character 凧 as its separated components 几巾; the standard character is restored here.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-rain-inhabited-house": { "id": "day-buson-rain-inhabited-house", "poet": "Yosa Buson", "form": "haiku", "japanese": "春雨や人住みて煙壁を洩る", "translation": "Spring rain—\nsomeone lives here; smoke\nseeps through the wall.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū)", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "rain", "house", "smoke", "spring" ], "notes": "Fresh working translation. Backup.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-rain-shells": { "id": "day-buson-rain-shells", "poet": "Yosa Buson", "form": "haiku", "japanese": "春雨や小磯の小貝ぬるるほど", "translation": "Spring rain—\njust enough to wet small shells\non a little shore.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū)", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "rain", "shells", "shore", "spring" ], "notes": "Fresh working translation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-river-crossing": { "id": "day-buson-river-crossing", "poet": "Yosa Buson", "form": "haiku", "japanese": "夏河を越すうれしさよ手に草履", "translation": "Such pleasure, crossing\nthe summer river—\nsandals in my hand.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū)", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "river", "feet", "summer", "crossing" ], "notes": "Fresh working translation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-spring-sea": { "id": "day-buson-spring-sea", "poet": "Yosa Buson", "form": "haiku", "japanese": "春の海終日のたりのたり哉", "translation": "The spring sea\nrolls slowly, rolls slowly,\nall day.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū)", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "sea", "waves", "spring", "daylight" ], "notes": "Fresh working translation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-two-houses": { "id": "day-buson-two-houses", "poet": "Yosa Buson", "form": "haiku", "japanese": "さみだれや大河を前に家二軒", "translation": "Summer rains—\nfacing the great river,\ntwo houses.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū)", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "rain", "river", "houses", "summer" ], "notes": "Fresh working translation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-buson-white-plum-dawn": { "id": "day-buson-white-plum-dawn", "poet": "Yosa Buson", "form": "haiku", "japanese": "しら梅に明る夜ばかりとなりにけり", "translation": "From now on,\nnights will break\non white plum blossoms.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "から檜葉 (Karahiba), headnote 初春", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Literature Museum", "imagery": [ "plum", "white", "dawn", "spring" ], "notes": "Fresh working translation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Karahiba", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "day-chiyoni-morning-glory-water": { "id": "day-chiyoni-morning-glory-water", "poet": "Fukuda Chiyo-ni", "form": "haiku", "japanese": "朝顔につるべとられてもらひ水", "translation": "Morning glories\nhave taken the well bucket—\nI ask for water elsewhere.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "千代尼句集 (Chiyo-ni kushū), Kihaku, ed., Hōreki 14 (1764)", "source_url": "https://www.city.hakusan.lg.jp/machi/ryokka/1003077.html", "source_label": "Hakusan City, Chiyo-ni documentation", "imagery": [ "morning glory", "water", "well", "vine", "bucket", "care" ], "notes": "Fresh working translation. Uses the に version printed in the 1764 collection. Hakusan's cultural-property record verifies this and discusses the や variant: https://www.city.hakusan.lg.jp/bunka/bunkazai/1006096/1002328/1009253.html .", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Chiyo-ni kushū (1764)", "author_japanese": "千代尼", "author_dates": "1703–1775" }, "day-issa-frog-mountain": { "id": "day-issa-frog-mountain", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "ゆうぜんとして山を見る蛙哉", "translation": "Serenely\ngazing at the mountain:\na frog.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "七番日記 (Shichiban nikki), Bunka 10 (1813)", "source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104001012&entry=4104&group=hirarajp&shid=6a9e69c2", "source_label": "Issa haiku database, entry 4104", "imagery": [ "frog", "mountain", "stillness", "spring", "watching" ], "notes": "Fresh working translation. Also independently attested by Nagareyama municipal poem-monument documentation: https://m.city.nagareyama.chiba.jp/life/1001505/1001700/1001730/1001735.html .", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shichiban nikki", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "day-issa-frog-on-turtle": { "id": "day-issa-frog-on-turtle", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "亀どのに負さつて鳴蛙哉", "translation": "Riding on\nMr. Turtle’s back,\na frog calls.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "七番日記 (Shichiban nikki), Bunka 12 (1815)", "source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104001012&entry=4116&group=hirarajp&shid=6a9e69c2", "source_label": "Issa haiku database, entry 4116", "imagery": [ "frog", "turtle", "pond", "spring", "voice" ], "notes": "Fresh working translation. どの humorously grants the turtle an honorific.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shichiban nikki", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "day-issa-orphan-sparrow": { "id": "day-issa-orphan-sparrow", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "我と来て遊べや親のない雀", "translation": "Come along with me\nand play, little sparrow\nwith no parents.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://www.issakinenkan.com/around/", "source_label": "Issa Memorial Museum, Myōsenji poem monument", "imagery": [ "sparrow", "play", "company", "spring" ], "notes": "Fresh working translation. Collection not specified by the museum's monument record.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "day-issa-proud-frog": { "id": "day-issa-proud-frog", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "草陰につんとしている蛙かな", "translation": "In the grass’s shade,\na frog sits\nwith its nose in the air.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "七番日記 (Shichiban nikki), Bunka 11 (1814)", "source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104001012&entry=4110&group=hirarajp&shid=6a9e69c2", "source_label": "Issa haiku database, entry 4110", "imagery": [ "frog", "grass", "shade", "spring", "stillness" ], "notes": "Fresh working translation. 'Nose in the air' renders the aloof or haughty posture of つんとしている rather than adding a literal action.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shichiban nikki", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hn01": { "id": "hn01", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "枯枝に烏のとまりけり秋の暮", "translation": "On a bare branch\na crow has settled—\nautumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "芭蕉俳句全集 (1903), 秋・秋の暮", "source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集", "source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), autumn section", "imagery": [ "crow", "bare branch", "autumn", "dusk" ], "notes": "The cited edition supplies the とまりけり version; other established versions differ. New working translation of this Japanese text. Historically a hokku.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn02": { "id": "hn02", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "此道や行く人なしに秋の暮", "translation": "This road—\nno one taking it\nin the autumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "芭蕉俳句全集 (1903), 秋・秋の暮; heading 所思", "source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集", "source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), autumn section", "imagery": [ "empty path", "autumn", "dusk", "solitude" ], "notes": "Do not count the alternative 人声やこの道かへる秋の暮 as an independent companion poem: it belongs to this poem's revision history.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn03": { "id": "hn03", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "名月や池をめぐりて夜もすがら", "translation": "The harvest moon—\naround the pond I walk\nthe whole night through.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "あつめ句 (source's attribution)", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/meigetu1.htm", "source_label": "Bashō Database: 名月や池をめぐりて夜もすがら", "imagery": [ "pond", "circular path", "moon", "whole night" ], "notes": "The subject is implicit in Japanese; the English supplies I. The source dates the poem to Jōkyō 3, eighth month, fifteenth night (1686).", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Atsume ku", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn04": { "id": "hn04", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "名月や門にさしくる潮がしら", "translation": "The harvest moon—\nthe leading edge of the tide\nreaches my gate.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/meigetu2.htm", "source_label": "Bashō Database: 名月や門にさしくる潮がしら", "imagery": [ "gate", "rising tide", "moon", "water boundary" ], "notes": "My is implicit rather than stated. Preserve the meeting of water and threshold; do not turn the tide into an ordinary garden stream.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn05": { "id": "hn05", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "荒海や佐渡によこたふ天河", "translation": "A rough sea—\nstretched over Sado,\nthe Milky Way.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "おくのほそ道, 越後路", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno28.htm", "source_label": "Bashō Database: Oku no hosomichi, Echigo road", "imagery": [ "rough sea", "island", "Milky Way", "night sky" ], "notes": "Sado is the named island, not a generic rock. The poem's arrangement need not be treated as a literal astronomical observation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Oku no hosomichi", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn08": { "id": "hn08", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "閑さや岩にしみ入蝉の声", "translation": "Stillness—\ninto the stone\nthe cicadas’ cry sinks.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "おくのほそ道, 立石寺", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno23.htm", "source_label": "Bashō Database: Oku no hosomichi, Risshakuji", "imagery": [ "stone", "cicadas", "stillness", "temple", "summer" ], "notes": "Japanese does not specify singular or plural cicadas. This is a summer daytime poem, suitable for the cicada motif but not evidence of nocturnal cicadas.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Oku no hosomichi", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn09": { "id": "hn09", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "初雪や水仙の葉のたわむまで", "translation": "The first snowfall—\nenough to bend\nthe narcissus leaves.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "あつめ句 (source's attribution)", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/hatyuki2.htm", "source_label": "Bashō Database: 初雪や水仙の葉のたわむまで", "imagery": [ "first snow", "narcissus", "bent leaves", "weight" ], "notes": "The cited source uses modern たわむ, retained here. Snow, not night, is explicit.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Atsume ku", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn10": { "id": "hn10", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "草の葉を落ちるよりとぶ螢哉", "translation": "From a blade of grass\nit drops—and is already flying:\na firefly.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "芭蕉俳句全集 (1903), 夏・螢", "source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集", "source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), summer section", "imagery": [ "firefly", "grass", "falling", "taking flight" ], "notes": "Working translation reads より as the immediate change from falling to flying. Preserve the tiny movement rather than adding a metaphysical explanation.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "hn11": { "id": "hn11", "poet": "Yosa Buson", "form": "haiku", "japanese": "菜の花や月は東に日は西に", "translation": "Rapeseed blossoms—\nthe moon in the east,\nthe sun in the west.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集; heading 春景", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "Hyōgo Prefectural Museum of Art, Edo–Hyōgo Haiku Gallery: Buson", "imagery": [ "flowers", "east", "west", "sunset", "moonrise", "opposed directions" ], "notes": "The Japanese names rapeseed blossoms; the translation does not add a color.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn12": { "id": "hn12", "poet": "Yosa Buson", "form": "haiku", "japanese": "月天心貧しき町を通りけり", "translation": "The moon at heaven’s center—\nI pass through\na poor town.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集", "source_url": "https://www.nippon.com/ja/japan-topics/b09645/", "source_label": "Hyōgo Prefectural Museum of Art, Edo–Hyōgo Haiku Gallery: Buson", "imagery": [ "high moon", "night walk", "town", "poverty" ], "notes": "天心 places the moon centrally overhead; it does not name a moon phase.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn13": { "id": "hn13", "poet": "Yosa Buson", "form": "haiku", "japanese": "門を出れば我も行人秋のくれ", "translation": "Outside my gate\nI too am someone on the road—\nautumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Letter to 了角 and 乙総, An’ei 3 (1774), ninth month, second day", "source_url": "https://gashuu.hateblo.jp/entry/2024/12/04/211201", "source_label": "剣持雅舟のブログ: 蕪村の句「門を出れば我も行く人」 (2024)", "imagery": [ "gate", "threshold", "traveler", "autumn", "dusk" ], "notes": "行人 is a passerby or traveler. The change in the speaker's status happens upon crossing the gate.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (a secondary source quoting the letter)", "display_source": "letter of 1774", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn15": { "id": "hn15", "poet": "Yosa Buson", "form": "haiku", "japanese": "鮎くれてよらで過行夜半の門", "translation": "He leaves me sweetfish\nand goes on without coming in—\nthe gate at midnight.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村句集; composed 1768", "source_url": "https://www.nippon.com/ja/japan-topics/b09625/", "source_label": "Nippon.com, 古典俳諧への招待: 鮎くれてよらで過行夜半の門 (2023)", "imagery": [ "gate", "visitor", "gift", "sweetfish", "midnight", "passing on" ], "notes": "Gender and recipient are supplied for readable English; Japanese leaves the subject unstated. 夜半 means the middle of the night, not necessarily exactly 00:00.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson kushū", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn16": { "id": "hn16", "poet": "Yosa Buson", "form": "haiku", "japanese": "燭の火を燭にうつすや春の夕", "translation": "A candle’s flame\npassed to another candle—\nspring evening.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "蕪村の俳句集, spring poems", "imagery": [ "candle", "flame", "transfer", "evening", "spring" ], "notes": "Japanese verified in this anthology and independently in the digitized 季題類別蕪村名句選集 search result. No original collection number established.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn17": { "id": "hn17", "poet": "Yosa Buson", "form": "haiku", "japanese": "夕風や水青鷺の脛を打つ", "translation": "The evening breeze—\nwater slaps\nthe heron’s shins.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://www.aozora.gr.jp/cards/000305/files/47985_41579.html", "source_label": "Masaoka Shiki, Haijin Buson, section 客観的美, at Aozora Bunko", "imagery": [ "evening breeze", "water", "heron", "legs", "ripples" ], "notes": "青鷺 is the grey heron; English uses the unqualified heron. The water, not the bird, is the subject of 打つ.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn19": { "id": "hn19", "poet": "Yosa Buson", "form": "haiku", "japanese": "霜百里舟中に我月を領ス", "translation": "A hundred leagues of frost—\nhere in my boat\nthe moon is mine.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "蕪村自筆句帳", "source_url": "https://www.nippon.com/ja/japan-topics/b09653/", "source_label": "Nippon.com, 古典俳諧への招待: 霜百里舟中に我月を領ス (2023)", "imagery": [ "frost", "boat", "moon", "night", "wide space" ], "notes": "百里 literally means a hundred ri; this working translation renders its immense reach idiomatically rather than falsely converting ri into miles. The source dates the composition tentatively to 1775.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Buson jihitsu kuchō", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "hn21": { "id": "hn21", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "うつくしや障子の穴の天の川", "translation": "How beautiful—\nthe Milky Way through a hole\nin the paper screen.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "志多良; Bunka 10 (1813)", "source_url": "https://www.issakinenkan.com/diary/一茶と善光寺⑨「上原文路宅跡」/", "source_label": "Issa Memorial Museum: 一茶と善光寺⑨「上原文路宅跡」 (2015)", "imagery": [ "paper screen", "hole", "Milky Way", "night sky", "framed view" ], "notes": "The museum ties it to Issa's illness and recovery at Uehara Bunro's house. This working translation adopts the ordinary literal view through the hole, without adding the medical context to the verse.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shidara", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hn22": { "id": "hn22", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "痩蛍ふはりふはりとながらふる", "translation": "A thin firefly—\ndrifting, drifting,\nit lives on.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "source_url": "https://www.issakinenkan.com/diary/ゲンジボタルについて学びませんか/", "source_label": "Issa Memorial Museum: ゲンジボタルについて学びませんか (2014)", "imagery": [ "firefly", "frailty", "floating", "survival" ], "notes": "ながらふる means to continue living; the translation keeps that meaning rather than merely saying it floats.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hn24": { "id": "hn24", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "我家や町の蛍の逃所", "translation": "My house—\nwhere the town’s fireflies\ncome to escape.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://www.issakinenkan.com/2014/06/", "source_label": "Issa Memorial Museum: ゲンジボタルの季節ですよ (2014-06-24)", "imagery": [ "house", "fireflies", "refuge", "town", "entrance" ], "notes": "逃所 is a place of escape or refuge. The Japanese does not specify what the fireflies escape from.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hn26": { "id": "hn26", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "夕燕我には翌のあてはなき", "translation": "Evening swallows—\nas for tomorrow,\nI have nothing to count on.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "文化句帖; database entry 3470", "source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104000746&group=hirarajp&s_entry=3460&se0=0&sf0=0&sk0=", "source_label": "Issa Haiku Database, entry 3470", "imagery": [ "swallows", "evening", "journey", "uncertain lodging", "tomorrow" ], "notes": "Entry 3470 has あてはなき; adjacent entries document different versions. Lodging is a plausible contextual interpretation of あて, but the translation does not insert it. The number is a database ID, not a numbering supplied by Issa.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Bunka kuchō", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hn29": { "id": "hn29", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "門の月暑がへれば友もへる", "translation": "Moon at the gate—\nas the heat lessens,\nso do the friends.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "八番日記; Bunsei 4 (1821)", "source_url": "https://www.janis.or.jp/users/kyodoshi/issaku-14.htm", "source_label": "Issa hokku zenshū (14), 残暑 section", "imagery": [ "gate", "moon", "waning heat", "fewer visitors", "late summer" ], "notes": "The source also records a variant ending 人もへる. The translation follows 友, friends, in the selected text.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Hachiban nikki", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hn30": { "id": "hn30", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "青空に指で字をかく秋の暮", "translation": "In the blue sky\nI write with my finger—\nautumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "七番日記; Bunka 11 (1814)", "source_url": "https://www.janis.or.jp/users/kyodoshi/issaku-14.htm", "source_label": "Issa hokku zenshū (14), 秋の暮 section", "imagery": [ "blue sky", "finger", "writing", "drawn trajectory", "autumn dusk" ], "notes": "The Japanese supplies no written word or character; the translation does not invent one.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shichiban nikki", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "hyakunin-14": { "id": "hyakunin-14", "poet": "Minamoto no Tōru", "form": "waka", "japanese": "みちのくの しのぶもぢずり 誰ゆゑに 乱れそめにし われならなくに", "translation": "Like Michinoku’s Shinobu cloth,\nits pattern tangled—\nfor whose sake\nhas my heart begun to tangle?\nNot by my own doing.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Ogura Hyakunin Isshu", "poem_number": "14", "source_url": "https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/14/", "source_label": "Bunka Net n.d., Hyakunin Isshu 14", "imagery": [ "cloth", "pattern", "disorder", "love" ], "notes": "The author is named by his title Kawara no Sadaijin in the source. Shinobu mojizuri is patterned dyed cloth; the image is not a literal woven lattice. The English makes the implied heart explicit.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Hyakunin isshu 14", "author_japanese": "源融", "author_dates": "822–895" }, "hyakunin-17": { "id": "hyakunin-17", "poet": "Ariwara no Narihira", "form": "waka", "japanese": "ちはやぶる 神代もきかず 竜田川 からくれなゐに 水くくるとは", "translation": "Not even\nin the age of mighty gods\nwas such a thing heard—\nTatsuta’s waters\ntied and dyed crimson.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ogura Hyakunin Isshu", "poem_number": "17", "source_url": "https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/17/", "source_label": "Bunka Net n.d., Hyakunin Isshu 17", "imagery": [ "river", "crimson", "dyed cloth", "pattern" ], "notes": "Uses the tie-dye reading of mizu kukuru; alternative readings of the last phrase exist. The cloth analogy matches the level’s patterned compartments, not its exact checkerboard design.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Hyakunin isshu 17", "author_japanese": "在原業平", "author_dates": "825–880" }, "new-basho-kiyotaki-moon": { "id": "new-basho-kiyotaki-moon", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "清滝や波に塵なき夏の月", "translation": "Kiyotaki—\non the waves, not a speck of dust:\nthe summer moon.", "collection": "1694; revised on his deathbed as 清滝や波に散り込む青松葉", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu2.htm", "imagery": [ "river", "waves", "summer moon", "clarity" ], "notes": "", "verification": "", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "translation_credit": "New translation by Claude, 2026-10-04", "display_source": "" }, "supp-basho-evening-wave-flowers": { "id": "supp-basho-evening-wave-flowers", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "夕晴れや桜に涼む波の華", "translation": "Evening clears—\nbeneath the cherry tree,\nthe cool bloom of waves.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "真蹟懐紙 (autograph kaishi), Kisakata", "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/kisagata.htm", "source_label": "Yamanashi Prefectural University Bashō database: 夕晴れや桜に涼む波の華", "imagery": [ "sea", "waves", "cherry tree", "evening", "clearing weather" ], "notes": "New working translation. The source explains 波の華 as the flowerlike glitter of waves, not the winter sea-foam phenomenon also called nami no hana. The headnote describes the evening rain stopping and a boat tour of the inlet.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "autograph sheet", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "supp-basho-hot-day-sea": { "id": "supp-basho-hot-day-sea", "poet": "Matsuo Bashō", "form": "haiku", "japanese": "暑き日を海に入れたり最上川", "translation": "The hot day\npoured into the sea—\nMogami River.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Sakata", "source_url": "https://www.thr.mlit.go.jp/yamagata/river/enc/genre/02-reki/reki0201_002.html", "source_label": "Japan Ministry of Land, Infrastructure, Transport and Tourism, Yamagata: Mogami River encyclopedia", "imagery": [ "sea", "river", "sun", "evening", "summer" ], "notes": "New working translation. 日 can evoke the hot day and the sun. The official explanation reads the river as washing the day's heat into the sea at evening.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Oku no hosomichi", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694" }, "supp-buson-lotus-fragrance": { "id": "supp-buson-lotus-fragrance", "poet": "Yosa Buson", "form": "haiku", "japanese": "蓮の香や水をはなるゝ莖二寸", "translation": "Lotus fragrance—\ntwo inches of stem\nclear of the water.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "蕪村集 (as indexed by the museum)", "source_url": "https://www2.city.kurashiki.okayama.jp/musnat/plant/bungakusakuhin/busonsyuu.htm", "source_label": "Kurashiki Museum of Natural History: 蕪村集の植物", "imagery": [ "lotus", "water", "fragrance", "summer" ], "notes": "New working translation. Source prints the old character 莖 (modern 茎). 二寸 is two traditional sun, approximately 6 cm; 'two inches' is a conventional approximate poetic rendering, not an exact metric conversion. Japanese also corroborated by https://www.manpukujitochigi.or.jp/zakki000063.html .", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "与謝蕪村", "author_dates": "1716–1784" }, "supp-issa-dragonfly-river": { "id": "supp-issa-dragonfly-river", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "蜻蛉の尻でなぶるや大井川", "translation": "With its tail\na dragonfly teases\nthe Ōi River.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "source_url": "https://gakusyu.shizuoka-c.ed.jp/japanese/syou_34/koten/03/shizuoka02.html", "source_label": "Shizuoka Prefecture Asunaro learning resources: 静岡ゆかりの俳句 2", "imagery": [ "dragonfly", "river", "water surface" ], "notes": "New working translation. Official educational page explicitly attributes all three displayed poems to Issa and explains the dragonfly touching the river with its small rear as though teasing the much larger river.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "supp-issa-owl-fireflies": { "id": "supp-issa-owl-fireflies", "poet": "Kobayashi Issa", "form": "haiku", "japanese": "梟や蛍蛍をよぶやうに", "translation": "An owl—\nas though calling,\n“Fireflies! Fireflies!”", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "七番日記, Bunka 7 (1810)", "source_url": "https://www.janis.or.jp/users/kyodoshi/issaku-11.htm", "source_label": "一茶発句全集(11), summer animals: fireflies", "imagery": [ "owl", "fireflies", "summer", "night" ], "notes": "New working translation. Source reads 梟や蛍 ~ をよぶやうに; its repetition symbol is expanded to 蛍蛍 here. Night is inferred from the owl/firefly scene; the Japanese does not explicitly contain the word night. Much closer to a nocturnal owl scene than Issa's first-autumn-morning poem.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shichiban nikki", "author_japanese": "小林一茶", "author_dates": "1763–1828" }, "teika-01": { "id": "teika-01", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "春のよの夢のうきはしとだえしてみねにわかるゝよこ雲のそら", "translation": "On a spring night\nthe floating bridge of dreams\nbreaks off—\nclouds stretch across the sky,\nparting at the peaks.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 38, "source_url": "https://www.wakapoetry.net/skks-i-38/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "dream", "broken bridge", "clouds", "spring night" ], "notes": "Keep the abrupt break; do not turn this into a moral about completing the puzzle.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 38" }, "teika-02": { "id": "teika-02", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "おほぞらはむめのにほひにかすみつゝくもりもはてぬ春のよの月", "translation": "The wide sky\nhazes with plum fragrance;\nnot wholly clouded,\nthe moon\nof a spring night.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 40, "source_url": "https://www.wakapoetry.net/skks-i-40/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "plum scent", "haze", "moon" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 40" }, "teika-03": { "id": "teika-03", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "むめの花にほひをうつす袖のうへにのきもる月のかげぞあらそふ", "translation": "On my sleeves,\nsteeped in plum-blossom scent,\nmoonlight\nleaking through the eaves\nvies with that fragrance.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 44, "source_url": "https://www.wakapoetry.net/skks-i-44/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "moonbeam", "eaves", "plum fragrance", "sleeves" ], "notes": "The competition between scent and light is in the poem; retain its synesthesia.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 44" }, "teika-04": { "id": "teika-04", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "しもまよふそらにしほれしかりがねのかへるつばさに春雨ぞふる", "translation": "Geese once wearied\nin a sky adrift with frost—\non their wings\nas they return,\nspring rain falls.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 63, "source_url": "https://www.wakapoetry.net/tag/teika/page/23/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "return", "geese", "frost", "spring rain" ], "notes": "The Japanese is verified on the tag page; the individual poem-page retrieval returned an internal error. The birds are geese, not swallows.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 63" }, "teika-06": { "id": "teika-06", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "さくら色の庭のはる風あともなしとはゞぞ人の雪とだにみん", "translation": "The cherry-colored\nspring wind in the garden\nhas left no trace;\na visitor\nwould see only snow.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 134, "source_url": "https://www.wakapoetry.net/skks-ii-134/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "garden", "petals", "erased traces", "snow" ], "notes": "Excellent literal garden pairing; the snow is a comparison for fallen blossoms.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 134" }, "teika-07": { "id": "teika-07", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "見わたせば花も紅葉もなかりけり浦のとまやの秋のゆふぐれ", "translation": "Looking out—\nno blossoms,\nno colored leaves.\nA thatched hut on the shore\nin the autumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 363, "source_url": "https://www.wakapoetry.net/skks-iv-363/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "absence", "plainness", "shore", "hut", "autumn dusk" ], "notes": "A strong alternative finale: absence and austerity, rather than praise of an elaborate finished pattern.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 363" }, "teika-08": { "id": "teika-08", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "さむしろやまつよの秋の風ふけて月をかたしくうぢのはしひめ", "translation": "On a narrow mat,\nin the autumn wind\nof a long night’s waiting,\nshe lies with only the moonlight—\nthe Lady of Uji Bridge.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 420, "source_url": "https://www.wakapoetry.net/skks-iv-420/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "straw mat", "waiting", "moonlight", "bridge" ], "notes": "Katashiku carries the loneliness of lying alone; moonlight becomes bedding.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 420" }, "teika-09": { "id": "teika-09", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "たび人の袖ふきかへす秋風にゆふひさびしき山のかけはし", "translation": "The autumn wind\nblows back\nthe traveler’s sleeves;\nlonely in the evening sun,\nthe cliffside bridge.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 953, "source_url": "https://www.wakapoetry.net/skks-x-953/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "traveler", "autumn wind", "sunset", "mountain footbridge" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 953" }, "teika-11": { "id": "teika-11", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "わするなよやどるたもとはかはるともかたみにしぼるよはの月かげ", "translation": "Do not forget—\nthough the sleeves that held it\nchange with time—\nthe midnight moonlight\nwe wrung out together.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 891, "source_url": "https://www.wakapoetry.net/skks-ix-891/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "parting", "shared moonlight", "sleeves", "remembrance" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 891" }, "teika-12": { "id": "teika-12", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "たまゆらのつゆも涙もとゞまらずなき人こふるやどの秋風", "translation": "Not for an instant\nwill dew or tears\nremain—\nautumn wind in the house\nwhere I mourn the dead.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 788, "source_url": "https://www.wakapoetry.net/skks-viii-788/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "dew", "tears", "autumn wind", "mourning", "house" ], "notes": "The headnote places this after his mother’s death; its grief should not be trivialized into a joke about failure.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 788" }, "teika-13": { "id": "teika-13", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "まつ人のふもとのみちはたえぬらんのきばのすぎに雪をもるなり", "translation": "Has the road below\nclosed to the one I await?\nOn the cedar\nbeside my eaves\nthe snow grows heavy.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 672, "source_url": "https://www.wakapoetry.net/skks-vi-672/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "blocked path", "waiting", "cedar", "snow" ], "notes": "The transmitted kana has 雪をもる; normalizing gives 雪おもる, snow grows heavy. Do not render this as a transitive act of piling snow.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 672" }, "teika-14": { "id": "teika-14", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "こまとめて袖うちはらふかげもなしさのゝわたりの雪のゆふぐれ", "translation": "No shelter\nwhere I might halt my horse\nand brush my sleeves:\nthe ford at Sano\nin the snowy dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 671, "source_url": "https://www.wakapoetry.net/skks-vi-671/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "no shelter", "snow", "crossing", "horse", "evening" ], "notes": "Kage here is shelter, not the assertion that the speaker casts no shadow. Also verified in Japanese at https://hyakuninisshu.com/97.html .", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 671" }, "teika-15": { "id": "teika-15", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "時わかぬなみさへ色にいづみがははゝそのもりに嵐ふくらし", "translation": "Even the waves,\nwhich know no seasons,\ntake on color—\na storm must be blowing\nthrough Izumi’s oak woods.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shinkokinwakashū", "poem_number": 532, "source_url": "https://www.wakapoetry.net/skks-v-532/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "waves", "colored leaves", "river", "storm" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 532" }, "teika-16": { "id": "teika-16", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "しぐれつる眞屋の軒端の程なきに頓てさしいる月の影かな", "translation": "After the shower,\nunder the cottage’s\nscant eaves,\nmoonlight\ncomes straight in.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Senzaiwakashū", "poem_number": 414, "source_url": "https://www.wakapoetry.net/szs-vi-414/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "shower", "eaves", "moonlight", "shelter" ], "notes": "Provisional: hodo naki is treated spatially, of the scant eaves; the poem also compresses the interval between rain and moonlight.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Senzaishū 414" }, "teika-17": { "id": "teika-17", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "冬來ては一夜ふたよをたま笹の葉わけの霜の處せきまで", "translation": "Winter has come.\nOne night, then two:\namong the bamboo leaves\nfrost thickens\nuntil no room remains.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Senzaiwakashū", "poem_number": 400, "source_url": "https://www.wakapoetry.net/szs-vi-400/", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "bamboo grass", "frost", "coverage", "winter" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Senzaishū 400" }, "teika-19": { "id": "teika-19", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "松風のひびきも色もひとつにてみどりに落つる谷川の水", "translation": "The pine wind’s\necho and color\nare one:\nthe valley stream\nfalls green.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Shūigūsō", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "pine wind", "stream", "green", "synesthesia" ], "notes": "Collection inferred from the source’s explicit rule that unnumbered selections are from Shūigūsō; exact poem number not supplied.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shūi gusō" }, "teika-20": { "id": "teika-20", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "夕立のくもまの日影はれそめて山のこなたをわたる白鷺", "translation": "Sunlight begins\nto clear the shower clouds;\na white egret\npasses on this side\nof the mountain.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Gyokuyōwakashū", "poem_number": 416, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "egret", "clearing shower", "sunlight", "mountain" ], "notes": "The poem is daylight, so Heron’s step is a much closer fit than Night heron.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Gyokuyōshū 416" }, "teika-21": { "id": "teika-21", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "こしかたはみな面影にうかびきぬ行末てらせ秋の夜の月", "translation": "Everything behind me\nfloats up\nin memory.\nLight what lies ahead,\nmoon of the autumn night.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Gyokuyōwakashū", "poem_number": 688, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "past", "future", "moonlight", "memory" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Gyokuyōshū 688" }, "teika-22": { "id": "teika-22", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "つくづくと明けゆく窓のともし火の有りやとばかりとふ人もなし", "translation": "I watch the lamp\nas the window grows light.\nNo one asks\neven whether\nI am still here.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04", "collection": "Gyokuyōwakashū", "poem_number": 2167, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "千人万首: 藤原定家 (unannotated selection)", "imagery": [ "lamp", "dawn", "solitude", "unvisited house" ], "notes": "The lamp and solitary speaker overlap; the translation preserves that indirectness.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Gyokuyōshū 2167" }, "teika-23": { "id": "teika-23", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "山のはの月まつ空のにほふより花にそむくる春のともし火", "translation": "Waiting for the moon,\nthe mountain-edge sky\nbegins to glow;\nI turn the spring lamplight\naway from the flowers.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Gyokuyōwakashū", "poem_number": 211, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "lamp", "moonrise", "flowers", "light" ], "notes": "", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Gyokuyōshū 211" }, "teika-24": { "id": "teika-24", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "吹きはらふ紅葉のうへの霧はれて峯たしかなるあらし山かな", "translation": "Swept by the wind,\nthe mist over the red leaves clears;\nthe peak stands sure:\nArashiyama,\nStorm Mountain.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Shūigūsō; Kanpaku Sadaijin-ke hyakushu (1232), topic: prospect", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "千人万首: 藤原定家 (unannotated selection)", "imagery": [ "mist clearing", "autumn leaves", "ridge", "clarity" ], "notes": "Exact anthology poem number not supplied by the consulted source. The source prints あらし山 in kana, which keeps the pun on arashi, storm, next to 吹きはらふ; the English glosses the name.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shūi gusō" }, "teika-25": { "id": "teika-25", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "風のうへに星のひかりは冴えながらわざともふらぬ霰をぞ聞く", "translation": "In the wind above,\nstarlight\nis cold and clear.\nI listen to hail\ntoo slight to see falling.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shūigūsō ingai; Bunshū hyakushu (1218)", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html", "source_label": "千人万首: 藤原定家 (unannotated selection)", "imagery": [ "stars", "wind", "hail", "listening" ], "notes": "Exact poem number not supplied by the consulted source.", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shūi gusō ingai" }, "teika-26": { "id": "teika-26", "poet": "Fujiwara no Teika", "author_japanese": "藤原定家", "author_dates": "1162–1241", "form": "waka", "japanese": "来ぬ人を松帆の浦の夕なぎに焼くやもしほの身もこがれつつ", "translation": "Waiting for someone\nwho does not come—\nat Matsuho, in the evening calm,\nthey burn seaweed for salt;\nso does my body burn.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ogura Hyakunin isshu; Shinchokusenwakashū", "poem_number": "97; 849", "source_url": "https://hyakuninisshu.com/97.html", "source_label": "Fujiwara no Teika n.d.", "imagery": [ "waiting", "evening calm", "sea", "salt fire", "longing" ], "notes": "Matsuho echoes matsu, to wait. The supplementary anthology number 849 is verified in the Asahi Teika text; a museum also confirms Hyakunin isshu 97 at https://www.meihaku.jp/hyakunin-isshu-kajin/kajin-fujiwarano-teika/ .", "verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Hyakunin isshu 97" }, "waka-other-01": { "id": "waka-other-01", "poet": "Saigyō", "form": "waka", "japanese": "岩間とぢし氷も今朝はとけそめて苔の下水みちもとむらん", "translation": "Ice that sealed the clefts\nstarts loosening this morning;\nunderneath the moss\nthe hidden water, surely,\nis searching for a path.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "7", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html", "source_label": "千人万首: 西行, Japanese text and anthology reference", "imagery": [ "moss", "water", "rocks", "ice", "spring", "pathfinding" ], "notes": "New five-line working translation from the cited Japanese. The conjectural ending -ramu is retained as 'surely'; this is an imagined search for a watercourse. The text is not in the principal Sankashū recension according to the source.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 7", "author_japanese": "西行", "author_dates": "1118–1190" }, "waka-other-02": { "id": "waka-other-02", "poet": "Saigyō", "form": "waka", "japanese": "吉野山こぞのしをりの道かへてまだ見ぬかたの花をたづねむ", "translation": "On Mount Yoshino\nI’ll leave the path I marked\nwith broken twigs last year\nand seek the blossoms\nin a place I haven’t seen.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "86", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html", "source_label": "千人万首: 西行, Japanese text and anthology reference", "imagery": [ "paths", "branching", "returning", "cherry blossoms", "mountains", "spring" ], "notes": "New working translation. Shiori means trail marks made by breaking branches; it is not a book's bookmark here. Particularly suitable for a level offering a new route through familiar ground.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 86", "author_japanese": "西行", "author_dates": "1118–1190" }, "waka-other-03": { "id": "waka-other-03", "poet": "Saigyō", "form": "waka", "japanese": "道の辺に清水ながるる柳蔭しばしとてこそ立ちとまりつれ", "translation": "Beside the road,\nclear water, willow shade—\nI had meant\nto stop here\nonly for a moment.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "262", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html", "source_label": "千人万首: 西行, Japanese text and anthology reference", "imagery": [ "road", "water", "willow", "shade", "rest", "summer" ], "notes": "New working translation. 'I had meant' carries the implication that the short stop became a longer stay. The cited text begins 道の辺に; some witnesses have 道の辺の.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 262", "author_japanese": "西行", "author_dates": "1118–1190" }, "waka-other-04": { "id": "waka-other-04", "poet": "Saigyō", "form": "waka", "japanese": "ゆくへなく月に心のすみすみて果てはいかにかならむとすらむ", "translation": "My heart grows clear,\nclearer still,\nendlessly in the moonlight.\nWhat will it become\nat last?", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04", "collection": "Sankashū", "poem_number": "353", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html", "source_label": "千人万首: 西行, Sankashū text and numbering", "imagery": [ "moon", "clarity", "mind", "limitlessness", "ending" ], "notes": "New working translation. Number follows the Shinpen Kokka Taikan numbering identified by the source. Yukue naku can suggest directionlessness or absence of a limit.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Sankashū 353", "author_japanese": "西行", "author_dates": "1118–1190" }, "waka-other-06": { "id": "waka-other-06", "poet": "Saigyō", "form": "waka", "japanese": "さびしさに堪へたる人のまたもあれな庵ならべむ冬の山里", "translation": "If only someone else\ncould bear this solitude—\nwe’d set our huts\nside by side\nin the winter mountain village.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "627", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html", "source_label": "千人万首: 西行, Japanese text and anthology references", "imagery": [ "solitude", "paired huts", "winter", "mountain village", "companionship" ], "notes": "New working translation. Also Sankashū 513 in the cited numbering. The wish is for company able to share solitude, not for solitude to be abolished.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 627", "author_japanese": "西行", "author_dates": "1118–1190" }, "waka-other-07": { "id": "waka-other-07", "poet": "Fujiwara no Shunzei", "form": "waka", "japanese": "夕されば野辺の秋風身にしみて鶉鳴くなり深草の里", "translation": "When evening comes,\nthe autumn wind across the fields\ncuts through me.\nQuail are calling\nin Fukakusa village.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Senzai Wakashū", "poem_number": "259", "source_url": "https://www.asahi-net.or.jp/~SG2H-ymst/yamatouta/sennin/syunzei2.html", "source_label": "千人万首: 藤原俊成, Japanese text and anthology reference", "imagery": [ "wind", "fields", "quail", "dusk", "autumn", "village" ], "notes": "New working translation. Fukakusa is a place name that also suggests deep grass. The source records the Ise monogatari allusion behind the quail; the English leaves that allusion implicit.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Senzaishū 259", "author_japanese": "藤原俊成", "author_dates": "1114–1204" }, "waka-other-08": { "id": "waka-other-08", "poet": "Fujiwara no Shunzei", "form": "waka", "japanese": "石ばしる水の白玉数見えて清滝川にすめる月影", "translation": "The white beads of water\ndashing on the rocks\ncan be counted,\nso clear the moonlight\non the Kiyotaki River.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Senzai Wakashū", "poem_number": "284", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syunzei.html", "source_label": "千人万首: 藤原俊成, Japanese text and anthology reference", "imagery": [ "water", "rocks", "moonlight", "droplets", "clarity", "counting" ], "notes": "New working translation. White jewels are droplets of spray made distinct by moonlight; Kiyotaki is retained as a river name.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Senzaishū 284", "author_japanese": "藤原俊成", "author_dates": "1114–1204" }, "waka-other-09": { "id": "waka-other-09", "poet": "Fujiwara no Shunzei", "form": "waka", "japanese": "ひとり見る池の氷にすむ月のやがて袖にもうつりぬるかな", "translation": "Alone I watch\nthe moon clear\non the frozen pond,\nand soon it shines\nin my sleeve as well.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "640", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syunzei.html", "source_label": "千人万首: 藤原俊成, Japanese text and anthology reference", "imagery": [ "reflection", "ice", "pond", "moon", "solitude", "tears" ], "notes": "New working translation. The sleeve is wet with tears and so receives the reflected moon; this remains implicit, as in the Japanese. Utsuru combines transfer and reflection.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 640", "author_japanese": "藤原俊成", "author_dates": "1114–1204" }, "waka-other-10": { "id": "waka-other-10", "poet": "Princess Shikishi", "form": "waka", "japanese": "山ふかみ春ともしらぬ松の戸にたえだえかかる雪の玉水", "translation": "Deep in the mountains,\nmy pinewood door\ndoes not yet know it’s spring:\none drop, then another,\npearls of melting snow.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "3", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syokusi.html", "source_label": "千人万首: 式子内親王, Japanese text and anthology reference", "imagery": [ "threshold", "door", "snowmelt", "mountains", "spring", "droplets" ], "notes": "New working translation. Matsu no to may be a pine-board door or a gate made from pine branches. The poet's name is also read Shokushi.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 3", "author_japanese": "式子内親王", "author_dates": "d. 1201" }, "waka-other-12": { "id": "waka-other-12", "poet": "Princess Shikishi", "form": "waka", "japanese": "かへりこぬ昔を今と思ひ寝の夢の枕ににほふ橘", "translation": "I sleep, thinking\nthe past that cannot return\nis here again;\norange blossom scents\nthe pillow of my dream.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "240", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syokusi.html", "source_label": "千人万首: 式子内親王, Japanese text and anthology reference", "imagery": [ "returning", "memory", "citrus blossom", "scent", "dream", "summer" ], "notes": "New working translation. Tachibana is the native Japanese citrus; its blossom scent conventionally recalls someone from the past.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 240", "author_japanese": "式子内親王", "author_dates": "d. 1201" }, "waka-other-13": { "id": "waka-other-13", "poet": "Eifukumon’in", "form": "waka", "japanese": "きりぎりす声はいづくぞ草もなきしらすの庭の秋の夜の月", "translation": "A cricket—\nwhere is that voice?\nNo grass grows here\nin the white-gravel courtyard\nunder the autumn moon.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Fūga Wakashū", "poem_number": "556", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html", "source_label": "千人万首: 永福門院, Japanese text and anthology reference", "imagery": [ "white gravel", "garden", "moon", "cricket", "absence", "autumn" ], "notes": "New working translation. Shirasu is a white-sand or white-gravel court. Classical kirigirisu denotes a cricket, not necessarily the insect called kirigirisu in modern Japanese. Especially close to the game's actual setting.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Fūgashū 556", "author_japanese": "永福門院", "author_dates": "1271–1342" }, "waka-other-14": { "id": "waka-other-14", "poet": "Eifukumon’in", "form": "waka", "japanese": "たきつ瀬や岩もとしろくよる花はながるとすれどまたかへるなり", "translation": "At the rapids,\nflowers gather white\nagainst a rock;\nthey start to drift away\nand then come back again.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Fūga Wakashū", "poem_number": "255", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html", "source_label": "千人万首: 永福門院, Japanese text and anthology reference", "imagery": [ "water", "rock", "eddies", "returning", "fallen blossoms", "spring" ], "notes": "New working translation. The returning movement is explicit; an eddy explains it but is not named in the Japanese. Suitable for loops or the mirrored return.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Fūgashū 255", "author_japanese": "永福門院", "author_dates": "1271–1342" }, "waka-other-15": { "id": "waka-other-15", "poet": "Eifukumon’in", "form": "waka", "japanese": "花の上にしばしうつろふ夕づく日入るともなしに影きえにけり", "translation": "For a little while\nthe evening sunlight lingers\non the blossoms;\nwithout seeming to set,\nits light has disappeared.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Fūga Wakashū", "poem_number": "199", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html", "source_label": "千人万首: 永福門院, Japanese text and anthology reference", "imagery": [ "twilight", "blossoms", "light", "disappearance", "spring" ], "notes": "New working translation. The cited anthology is Fūgashū, not Gyokuyōshū; some popular pages misidentify it. The unnoticed loss of light supplies the action.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Fūgashū 199", "author_japanese": "永福門院", "author_dates": "1271–1342" }, "waka-other-17": { "id": "waka-other-17", "poet": "Eifukumon’in", "form": "waka", "japanese": "ま萩ちる庭の秋風身にしみて夕日のかげぞ壁に消えゆく", "translation": "Bush-clover flowers fall.\nThe garden’s autumn wind\ngoes through me;\nevening sunlight fades\nupon the wall.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Fūga Wakashū", "poem_number": "478", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html", "source_label": "千人万首: 永福門院, Japanese text and anthology reference", "imagery": [ "garden", "wall", "wind", "falling flowers", "sunlight", "autumn" ], "notes": "New working translation. Hagi is bush clover; this is autumn blossom fall, not spring cherry blossom.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Fūgashū 478", "author_japanese": "永福門院", "author_dates": "1271–1342" }, "waka-other-18": { "id": "waka-other-18", "poet": "Izumi Shikibu", "form": "waka", "japanese": "春霞たつやおそきと山川の岩間をくぐる音きこゆなり", "translation": "Impatient, perhaps,\nfor the spring haze to rise,\nthe mountain stream\nworks its way between rocks;\nI hear the sound.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Goshūi Wakashū", "poem_number": "13", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/izumi_n.html", "source_label": "千人万首: 和泉式部, Japanese text and anthology reference", "imagery": [ "water", "rocks", "spring haze", "passage", "sound" ], "notes": "New working translation. The opening gives the water an apparent impatience at the haze's delay; the ending locates the perception in hearing.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Goshūishū 13", "author_japanese": "和泉式部", "author_dates": "fl. c. 1000" }, "waka-other-21": { "id": "waka-other-21", "poet": "Jakuren", "form": "waka", "japanese": "さびしさはその色としもなかりけり槙立つ山の秋の夕暮", "translation": "Solitude\nhas no color of its own.\nThe mountains\nstand thick with evergreens\nin the autumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": "361", "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/jakuren.html", "source_label": "千人万首: 寂蓮法師, Japanese text and anthology reference", "imagery": [ "solitude", "colorlessness", "evergreens", "mountains", "autumn dusk" ], "notes": "New working translation. Maki here is a collective term for conifers such as cedar and cypress; translating it specifically as pine would be needlessly narrow. One of the three celebrated autumn-dusk poems.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Shinkokinshū 361", "author_japanese": "寂蓮", "author_dates": "c. 1139–1202" }, "waka-other-23": { "id": "waka-other-23", "poet": "Jien", "form": "waka", "japanese": "旅の世にまた旅寝して草まくら夢のうちにも夢をみるかな", "translation": "On this journey of a life\nI sleep away from home again,\ngrass for my pillow—\nand even within a dream\nI dream.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Senzai Wakashū", "poem_number": "533", "source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/jien.html", "source_label": "千人万首: 慈円, Japanese text and anthology reference", "imagery": [ "journey", "rest", "grass pillow", "dream", "nested worlds" ], "notes": "New working translation. Life itself is treated as a journey and a dream, with literal traveling and dreaming nested within it.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Senzaishū 533", "author_japanese": "慈円", "author_dates": "1155–1225" }, "waka-other-25": { "id": "waka-other-25", "poet": "Ki no Tsurayuki", "form": "waka", "japanese": "人はいさ心も知らずふるさとは花ぞ昔の香ににほひける", "translation": "As for people—\nI cannot know their hearts.\nHere at this old place,\nthe plum blossoms\nstill give their old scent.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Kokin Wakashū", "poem_number": "42", "source_url": "https://www.karuta.or.jp/karuta-everyday/2815/", "source_label": "All Japan Karuta Association: 人はいさ, Japanese poem and original anthology reference", "imagery": [ "plum", "scent", "returning", "memory", "unchanged place", "spring" ], "notes": "New working translation. Also Ogura Hyakunin Isshu 35. The headnote identifies the blossoms as plum and the old place as a previously visited lodging; furusato need not mean the poet's birthplace.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Kokinshū 42", "author_japanese": "紀貫之", "author_dates": "c. 872–945" }, "waka-other-26": { "id": "waka-other-26", "poet": "Ōshikōchi no Mitsune", "form": "waka", "japanese": "心あてに折らばや折らむ初霜のおきまどはせる白菊の花", "translation": "If I pick one,\nit must be by guess—\nwhite chrysanthemums\nlost in\nthe first frost.", "translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04", "collection": "Kokin Wakashū", "poem_number": "277", "source_url": "https://www.karuta.or.jp/karuta-everyday/2791/", "source_label": "All Japan Karuta Association: 心あてに, Japanese poem and original anthology reference", "imagery": [ "frost", "white flowers", "chrysanthemums", "concealment", "indistinguishability", "autumn" ], "notes": "New working translation. Also Ogura Hyakunin Isshu 29. The conceit is that frost and white petals cannot readily be distinguished.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Kokinshū 277", "author_japanese": "凡河内躬恒", "author_dates": "fl. c. 900" }, "waka-other-28": { "id": "waka-other-28", "poet": "Harumichi no Tsuraki", "form": "waka", "japanese": "山川に風のかけたるしがらみは流れもあへぬもみぢなりけり", "translation": "Across the mountain stream\nthe wind has built a weir:\nfallen autumn leaves\nthat could not\nmake their way downstream.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Kokin Wakashū", "poem_number": "303", "source_url": "https://www.karuta.or.jp/karuta-everyday/2803/", "source_label": "All Japan Karuta Association: 山川に, Japanese poem and original anthology reference", "imagery": [ "stream", "wind", "barrier", "leaves", "autumn", "blocked passage" ], "notes": "New working translation. Also Ogura Hyakunin Isshu 32. Shigarami is a weir or fence across a stream; it is formed here by a jam of leaves, not a constructed object.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "Kokinshū 303", "author_japanese": "春道列樹", "author_dates": "d. 920" }, "waka-other-29": { "id": "waka-other-29", "poet": "Ryōkan", "form": "waka", "japanese": "山かげの岩間をつたふ苔水のかすかに我はすみわたるかも", "translation": "In the mountain shade,\nwater threads the moss\nbetween the rocks;\nlike that faint flow\nI quietly dwell.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ryōkan Oshō kashū (collected waka; cited edition edited by Sōma Gyofū)", "source_url": "https://ryoukan-w.info/?page_id=1315", "source_label": "良寛ワールド: 良寬 珠玉の言葉 11~20", "imagery": [ "moss", "water", "rocks", "shade", "solitude", "quiet dwelling" ], "notes": "New working translation. No poem number verified. Sumi combines dwelling and being clear; the English prioritizes dwelling, with the clear-water implication retained by the image.", "reverified": "yes, 2026-10-04: checked against source_url, which prints it in modern kana as 「やまかげの 岩間を伝う 苔水の かすかに我は すみわたるかも」", "display_source": "", "author_japanese": "良寛", "author_dates": "1758–1831" }, "waka-other-30": { "id": "waka-other-30", "poet": "Ryōkan", "form": "waka", "japanese": "形見とて何か残さむ春は花山ほととぎす秋はもみぢ葉", "translation": "What could I leave\nto remember me by?\nSpring blossoms,\nthe mountain cuckoo,\nautumn leaves.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Ryōkan zenshū, lower volume (Tokyo Sōgensha, 1959), p. 195; surviving calligraphy", "poem_number": "1238", "source_url": "https://crd.ndl.go.jp/reference/entry/index.php?id=1000101479&page=ref_view", "source_label": "Osaka Prefectural Central Library, NDL Collaborative Reference Database: Ryōkan textual variants", "imagery": [ "inheritance", "blossoms", "cuckoo", "autumn leaves", "seasons", "farewell" ], "notes": "New working translation. The library documents this precise mountain-cuckoo version as no. 1238, and a summer-cuckoo variant as no. 1239. Do not silently blend the versions or present this as a securely dated deathbed composition.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "display_source": "", "author_japanese": "良寛", "author_dates": "1758–1831" }, "new-basho-sweep-garden": { "id": "new-basho-sweep-garden", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "庭掃て出ばや寺に散柳", "translation": "Let me sweep the garden\nbefore I go—\nwillow leaves fall in the temple.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Zenshōji, journey of 1689", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno34.htm", "source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 曾良との別れ", "display_source": "Oku no hosomichi", "notes": "Leaving the temple where he had lodged, Bashō sweeps its garden as a guest's thanks; the young monks had run after him with paper and inkstone. The 1903 Zenshū prints 精舍 for 寺.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "waka-other-11": { "id": "waka-other-11", "poet": "Princess Shikishi", "form": "waka", "japanese": "窓ちかき竹の葉すさぶ風の音にいとどみじかきうたたねの夢", "translation": "By the window,\nwind sets bamboo leaves\nrustling;\nmy dozing dream\nis shorter still.", "translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": 256, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syokusi.html", "source_label": "千人万首: 式子内親王, Japanese text and anthology reference", "imagery": [ "bamboo", "wind", "window", "dream", "rest", "summer" ], "notes": "New working translation. The sound interrupts a light sleep; no waking character or specific dream content has been added.", "author_japanese": "式子内親王", "author_dates": "d. 1201", "display_source": "Shinkokinshū 256", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (新古256, headnote 百首の歌たてまつりし時)" }, "new-tomonori-hi33": { "id": "new-tomonori-hi33", "poet": "Ki no Tomonori", "author_japanese": "紀友則", "author_dates": "d. c. 905", "form": "waka", "japanese": "ひさかたの 光のどけき 春の日に しづ心なく 花の散るらむ", "translation": "In the tranquil light\nof a spring day\nunder the wide sky—\nwhy, with no calm in their hearts,\ndo the blossoms fall?", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "Ogura Hyakunin Isshu; Kokin Wakashū 84", "poem_number": 33, "source_url": "https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/33/", "source_label": "Bunka Net n.d., Hyakunin Isshu 33", "display_source": "Hyakunin isshu 33", "notes": "しづ心 is a calm, settled heart; the garden Calm scores exactly that.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-ryokan-new-pond": { "id": "new-ryokan-new-pond", "poet": "Ryōkan", "author_japanese": "良寛", "author_dates": "1758–1831", "form": "haiku", "japanese": "新池や蛙とびこむ音もなし", "translation": "The new pond—\na frog jumps in;\nnot a sound.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "Ryōkan zenkushū (ed. Tanikawa Toshirō), as cited by the source", "poem_number": null, "source_url": "https://sakuramitih31.livedoor.blog/archives/41933640.html", "additional_source_url": "https://idea1616.com/ryokan-haiku/", "source_label": "名歌鑑賞: 新池や蛙とびこむ音もなし (blog, citing 谷川敏朗『良寛全句集』)", "display_source": "", "notes": "Ryōkan's answer to Bashō's old pond: the new pond has no sound. Attribution is the traditional one; the text was checked against two secondary pages, not a critical edition.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (secondary sources)" }, "new-basho-summer-room": { "id": "new-basho-summer-room", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "山も庭も動き入るゝや夏座敷", "translation": "Mountain and garden\ncome moving in—\nthe summer room.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "芭蕉俳句全集 (1903), 夏・夏座敷; composed 1689 at Kurobane", "poem_number": null, "source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集", "source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), summer section", "display_source": "Kurobane, 1689", "notes": "A greeting verse for his host's open summer room at Kurobane, on the Oku no hosomichi journey: the view enters the room.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-hida": { "id": "new-basho-hida", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "涼しさを飛騨の工が指図かな", "translation": "Coolness—\ndrawn to the plan\nof a Hida carpenter.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "Letter to Sugiyama Sanpū, Genroku 7, intercalary fifth month, day 11 (1694)", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu1.htm", "source_label": "Yamanashi Prefectural University, Bashō database: letter to Sanpū (1)", "display_source": "letter of 1694", "notes": "指図 is an architect's drawing or direction; the carpenters of Hida were the proverbial master builders. Bashō later preferred the variant 涼しさの指図に見ゆる住まゐかな.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-hikarido": { "id": "new-basho-hikarido", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "五月雨の降のこしてや光堂", "translation": "The summer rains—\ndid they spare only this?\nThe Hall of Light.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Hiraizumi, journey of 1689", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno20.htm", "source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 平泉", "display_source": "Oku no hosomichi", "notes": "The Konjikidō at Chūsonji, kept whole for five centuries while everything around it fell; 降りのこす is to leave something unrained-on.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-buson-dew-thorns": { "id": "new-buson-dew-thorns", "poet": "Yosa Buson", "author_japanese": "与謝蕪村", "author_dates": "1716–1784", "form": "haiku", "japanese": "白露や茨の刺にひとつづつ", "translation": "White dew—\non each thorn of the bramble,\none drop.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū), autumn", "poem_number": null, "source_url": "https://idea1616.com/buson/", "additional_source_url": "https://ameblo.jp/mtada99/entry-12474301599.html", "source_label": "与謝蕪村の俳句 102選 (list), and a commentary page", "display_source": "Buson kushū", "notes": "Checked against two secondary pages (the museum and bonjin lists used elsewhere do not carry it).", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (secondary sources)" }, "new-buson-fuji-leaves": { "id": "new-buson-fuji-leaves", "poet": "Yosa Buson", "author_japanese": "与謝蕪村", "author_dates": "1716–1784", "form": "haiku", "japanese": "不二ひとつうづみ残して若葉かな", "translation": "Young leaves—\nthey have buried everything\nbut Fuji.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "蕪村句集 (Buson kushū), summer", "poem_number": null, "source_url": "https://bonjin-ultra.com/buson.htm", "source_label": "bonjin-ultra: 蕪村の俳句, summer", "display_source": "Buson kushū", "notes": "うづみ残す: to leave one thing unburied.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-ishiyama": { "id": "new-basho-ishiyama", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "石山の石より白し秋の風", "translation": "Whiter\nthan the stones of Stone Mountain—\nthe autumn wind.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Natadera, journey of 1689", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno33.htm", "source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 那谷", "display_source": "Oku no hosomichi", "notes": "Autumn's color is white in the five-phase scheme; the wind outdoes the white rocks of Nata (or of Ishiyama) in it.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-issa-snail": { "id": "new-issa-snail", "poet": "Kobayashi Issa", "author_japanese": "小林一茶", "author_dates": "1763–1828", "form": "haiku", "japanese": "かたつぶりそろそろ登れ富士の山", "translation": "Little snail,\nslowly, slowly climb—\nMount Fuji.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "一茶発句集 (Issa hokkushū), Bunsei edition (1829, posthumous)", "poem_number": null, "source_url": "https://crd.ndl.go.jp/reference/entry/index.php?page=ref_view&id=1000180048", "source_label": "NDL Collaborative Reference Database: the collections that carry the snail verse", "display_source": "Issa hokkushū (1829)", "notes": "Not in Issa's own diaries; first printed two years after his death (the Kaei edition has 不二の山). Text as the Bunsei edition prints it, per the reference record.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-one-field": { "id": "new-basho-one-field", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "田一枚植て立去る柳かな", "translation": "One field planted—\nand I leave\nthe willow.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), the Yugyō willow, journey of 1689", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno07.htm", "source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 遊行柳", "display_source": "Oku no hosomichi", "notes": "Under Saigyō's willow; who plants the field (the women, or Bashō's party) is left open, as is who leaves.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-buson-dry-spring": { "id": "new-buson-dry-spring", "poet": "Yosa Buson", "author_japanese": "与謝蕪村", "author_dates": "1716–1784", "form": "haiku", "japanese": "柳ちり清水かれ石ところどころ", "translation": "Willow leaves fallen,\nthe spring run dry,\nstones here and there.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "蕪村集 (as indexed by the museum)", "poem_number": null, "source_url": "https://www2.city.kurashiki.okayama.jp/musnat/plant/bungakusakuhin/busonsyuu.htm", "source_label": "Kurashiki Museum of Natural History: 蕪村集の植物", "display_source": "", "notes": "An answer to Saigyō's roadside willow and its clear water (Stepping stone): the water gone, the stones left.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-ryokan-thief-moon": { "id": "new-ryokan-thief-moon", "poet": "Ryōkan", "author_japanese": "良寛", "author_dates": "1758–1831", "form": "haiku", "japanese": "盗人に取り残されし窓の月", "translation": "The thief\nleft it behind:\nthe moon in the window.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "Ryōkan's hokku; the anecdote of the thief at his hut", "poem_number": null, "source_url": "https://nichi-zen.site/remaining-moon/", "additional_source_url": "https://www.jomo-news.co.jp/articles/-/5483", "source_label": "日常実践の禅: 盗人に取り残されし窓の月", "display_source": "", "notes": "The thief found nothing worth taking (one telling has Ryōkan roll over so the man could take his bedding). Attribution is the traditional one; checked against secondary pages.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (secondary sources)" }, "new-manyo-1068": { "id": "new-manyo-1068", "poet": "Kakinomoto no Hitomaro", "author_japanese": "柿本人麻呂歌集", "author_dates": "fl. c. 680–700", "form": "waka", "japanese": "天の海に雲の波立ち月の船星の林に榜ぎ隠る見ゆ", "translation": "On the sea of heaven\nthe waves of cloud rise;\nthe boat of the moon\nrows into the forest of stars\nand is hidden from sight.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "Man'yōshū, book 7 (from the Hitomaro Collection)", "poem_number": 1068, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/manyok/manyok07.html", "source_label": "訓読万葉集 巻7 (Yamato Uta), after Kamochi Masazumi's 萬葉集古義", "display_source": "Man'yōshū 1068", "notes": "Headed 天を詠める and marked as from the Kakinomoto no Hitomaro Collection, whose poems are not all his; the source prints 榜ぎ for 漕ぎ.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-sea-darkens": { "id": "new-basho-sea-darkens", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "海くれて鴨のこゑほのかに白し", "translation": "The sea darkens;\nthe voices of the ducks\nfaintly white.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "野ざらし紀行 (Nozarashi kikō), Atsuta, winter 1684", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/nozarasi/nozara23.htm", "source_label": "Yamanashi Prefectural University, Bashō database: Nozarashi kikō, 海邊に日暮して", "display_source": "Nozarashi kikō", "notes": "Headnote 海邊に日暮して. A sound given a color: the source calls it one of his finest verses.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-moon-sand": { "id": "new-basho-moon-sand", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "月清し遊行のもてる砂の上", "translation": "The moon is clear\non the sand\nthe pilgrim monk carried.", "translation_credit": "New translation by Claude, 2026-10-04", "collection": "おくのほそ道 (Oku no hosomichi), Tsuruga (Kehi shrine), journey of 1689", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno38.htm", "source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 敦賀", "display_source": "Oku no hosomichi", "notes": "遊行 is the Yugyō Shōnin, head of the Ji sect: the second, Ta'a, carried sand himself to fill the shrine's muddy grounds, and his successors keep the rite (遊行の砂持ち). The 1903 Zenshū prints 月淸し遊行の持てる砂の上.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-shiki-bridge-fish": { "id": "new-shiki-bridge-fish", "poet": "Masaoka Shiki", "author_japanese": "正岡子規", "author_dates": "1867–1902", "composed": 1895, "form": "haiku", "japanese": "橋蹈めば魚沈みけり春の水", "translation": "I step on the bridge—\nthe fish sink.\nSpring water.", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass), revised by Claude, 2026-10-04", "collection": "獺祭書屋俳句帖抄 (Dassai shooku haiku chō shō), spring 1895", "poem_number": null, "source_url": "https://mukei-r.net/poem-masaoka/00-haiku-dassai1.htm", "source_label": "獺祭書屋俳句帖抄 (transcription), 明治28年 春", "display_source": "1895", "notes": "The source prints 沈みけり (Astra's draft had the fish sink \"deeper\"). Composed 1895; the poet died in 1902, so this is the one poem whose author outlived 1900.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-fushimi-lotus-dew": { "id": "new-fushimi-lotus-dew", "poet": "Emperor Fushimi", "author_japanese": "伏見院", "author_dates": "1265–1317", "form": "waka", "japanese": "こぼれ落つる池のはちすの白露は浮葉の玉とまたなりにけり", "translation": "Dew spills\nfrom the pond's lotus leaves;\non the floating leaves\nit becomes\na jewel again.", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass), revised by Claude, 2026-10-04", "collection": "Gyokuyō Wakashū (headnote: 百首御歌の中に、蓮を)", "poem_number": null, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html", "source_label": "和歌歳時記: 蓮葉 (Yamato Uta)", "display_source": "Gyokuyōshū", "notes": "Lotus, not water lily; the number in the Gyokuyōshū is not given by the source.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-tamemasa-carp-lotus": { "id": "new-tamemasa-carp-lotus", "poet": "Reizei Tamemasa", "author_japanese": "冷泉為尹", "author_dates": "1361–1417", "form": "waka", "japanese": "池水に藻臥しの鮒や乱るらん蓮のうき葉のゆるぎ立ちぬる", "translation": "Hidden in the pondweed,\nare the carp stirring?\nThe lotus leaves\nfloating on the water\nhave begun to tremble.", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04", "collection": "Tamemasa senshu (為尹千首), topic: pond lotus", "poem_number": null, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html", "source_label": "和歌歳時記: 蓮葉 (Yamato Uta)", "display_source": "Tamemasa senshu", "notes": "鮒 is the crucian carp. Astra credited the poem to Reizei Tamehide; the source gives 為尹 (Tamemasa, Tamehide's grandson).", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-grass-hut": { "id": "new-basho-grass-hut", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "草の戸も住替る代ぞひなの家", "translation": "Even this grass hut\nchanges hands—\na house of dolls.", "collection": "Oku no hosomichi (opening hokku)", "poem_number": null, "source_url": "https://basho-yamadera.com/en/yamadera/kusanotomo", "imagery": [ "grass hut", "new occupants", "dolls", "change" ], "notes": "", "verification": "", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass), revised by Claude, 2026-10-04", "display_source": "Oku no hosomichi" }, "waka-other-05": { "id": "waka-other-05", "poet": "Saigyō", "form": "waka", "japanese": "心なき身にもあはれは知られけり鴫たつ沢の秋の夕暮", "translation": "Even one like me,\nwho has set his heart aside,\nfeels it after all—\na snipe lifts from the marsh\nat autumn dusk.", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04", "collection": "Shin Kokin Wakashū", "poem_number": 362, "source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html", "source_label": "千人万首: 西行, Japanese text and anthology references", "imagery": [ "marsh", "bird", "autumn", "dusk", "detachment", "feeling" ], "notes": "New working translation. Also Sankashū 470 in the cited numbering. Kokoro naki can mean a renunciant freed of attachments or, more modestly, someone supposedly insensitive to beauty; this translation chooses the renunciant reading. One of the three celebrated autumn-dusk poems.", "author_japanese": "西行", "author_dates": "1118–1190", "display_source": "Shinkokinshū 362", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-buson-dancers": { "id": "new-buson-dancers", "poet": "Yosa Buson", "author_japanese": "与謝蕪村", "author_dates": "1716–1784", "form": "haiku", "japanese": "四五人に月落かかるおどりかな", "translation": "Four or five dancers—\nthe moon\nfalls toward them.", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04", "collection": "Inscribed on his painting 踊図 (Dancers; Agency for Cultural Affairs)", "poem_number": null, "source_url": "https://www.kyuhaku.jp/exhibition/exhibition_s50.html", "source_label": "Kyushu National Museum: 王羲之と日本の書 (exhibit list)", "display_source": "on his painting 踊図", "notes": "The Bon dance; 月落ちかかる is the moon sinking low over the dancers.", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" }, "new-basho-moon-clouds": { "id": "new-basho-moon-clouds", "poet": "Matsuo Bashō", "author_japanese": "松尾芭蕉", "author_dates": "1644–1694", "form": "haiku", "japanese": "雲折々人をやすむる月見かな", "translation": "Watching the moon—\nevery so often a cloud\nlets us rest.", "translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04", "collection": "春の日 (Haru no hi), 1686", "poem_number": null, "source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/shitibusyu/harunohi.htm", "source_label": "Yamanashi Prefectural University, Bashō database: 俳諧七部集, 春の日", "display_source": "Haru no hi (1686)", "notes": "", "reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url" } } } poems/astra-samon-poem-selection.json The sounds: the recordings' credits and their encoder (the synthesis is in the prototype) (sound/ ) sound/README.md # Sound The page's sounds are of two kinds. The bamboo pipe's drips, its hollow knock as it tips, the _bonshō_ at the close of a world, the seal's _tok_, the wind (a bed with gusts), the birds by day and crickets by night (by the local clock), the footsteps, and the rake's scrape (a looping synthesized crackle through a bandpass, driven frame by frame by the reveal's stroke speed, with a sideways rasp in bends) are synthesized in the page (the `SND` block in `proto/samon.template.html`: decaying sines, filtered noise and crackle, a short feedback delay for the basin's echo). They cost nothing to carry. `renderSound` (a test hook) renders any sequence of them offline to WAV, which is how the tests check that each is audible and how they were listened to. A volume slider and a haptics switch (`navigator.vibrate`, 8 ms per cell, shown only where it exists) sit beside the Sound switch; a speaker button in the header (and the M key) is the same switch. The page sets `navigator.audioSession.type = "ambient"` where that API exists (Safari 17), registers Media Session pause/stop/play handlers, and shows the speaker in red when the browser keeps the context from running after a gesture (`soundBlocked`: Firefox's `getAutoplayPolicy("audiocontext")` saying "disallowed", or a context still not running 1.5 s after the gesture). There is no CSS media query for a sound preference. The shakuhachi is real: recordings in the public domain, listed in `tracks.json` with their credits and sources. `make_sound_bundle.py` measures each one's loudness (EBU R128), brings it to −20 LUFS with a static gain (no dynamics processing), encodes it as mono MP3 (LAME VBR 6; MP3 because desktop Safari still does not play Opus in Ogg), and writes `out/sound_pieces.json`, which `proto/build.py` and `standalone/build_standalone.py` write into the page as inert `
      Samon: Designing (ORG) a Zen Garden Raking Puzzle Zen (PERSON) a Zen temple (LOCATION) Samon (PERSON) The Garden Pieces (LOCATION) Kyoto (LOCATION) Shirakawa-suna (LOCATION) Kitashirakawa (LOCATION) JAANUS (PERSON) Ryōan-ji (PERSON) Hamiltonian (ORG)
      Originally published by Hacker News Read original →