From 2e2f00143d9cbd6be12904118acd5db19950c183 Mon Sep 17 00:00:00 2001 From: Waffle3z Date: Thu, 30 Jul 2026 19:54:45 -0400 Subject: [PATCH 1/3] Reject a Yellow reply that wins the game check_yellow_children skipped any reply that made four for Yellow, which asks nothing of the position. A Red move that simply loses was caught only when some unrelated reply happened to be uncovered, and reported as that reply's problem. Set branches["4265626642"] to "4", where Yellow threatens four in column 2, and check 5 now fails with: ['4265626642', '4', '2', 'Yellow wins the game with this reply'] Before, it named column 4 instead, and would have said nothing at all had the rest been covered. The shipped solution passes all nine checks either way, which is why the rewrite could drop this without the data noticing. Same gap as 94fc474. --- solution/validate_solution.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/solution/validate_solution.py b/solution/validate_solution.py index 64fa39b..50a9e78 100644 --- a/solution/validate_solution.py +++ b/solution/validate_solution.py @@ -358,8 +358,10 @@ def check_yellow_children(branches, steady_states): continue child = [row[:] for row in board] child[y][x] = 2 + # Red's committed move lost the game, so nothing below matters. if makes_four(child, x, y, 2): - continue + raise AssertionError([position, rmove, ymove, + "Yellow wins the game with this reply"]) child_key = tuple(tuple(row) for row in child) canon = min(child_key, mirror_key(child_key)) From a58b45e725533b749fe9d159b0eed4be0cb7daa6 Mon Sep 17 00:00:00 2001 From: Waffle3z Date: Thu, 30 Jul 2026 19:55:19 -0400 Subject: [PATCH 2/3] Print the results table when stdout is not a console A real Windows console handles the check marks the table uses, but stdout falls back to the locale's ANSI codepage as soon as it is anything else, and cp1252 has no code point for them. So `python solution/validate_solution.py`, the command CONTRIBUTING gives, ended in UnicodeEncodeError and exited 1 whenever it was piped, redirected, or run from Git Bash, after all nine checks had already passed. --- solution/validate_solution.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/solution/validate_solution.py b/solution/validate_solution.py index 50a9e78..80b0da5 100644 --- a/solution/validate_solution.py +++ b/solution/validate_solution.py @@ -418,6 +418,11 @@ def main(): help="parallel processes (default: one per core)") args = parser.parse_args() + # On Windows stdout falls back to the ANSI codepage whenever it is not a + # console (a pipe, a redirect, Git Bash), and cp1252 cannot encode the + # marks the table below is drawn with. + sys.stdout.reconfigure(encoding="utf-8") + jobs = args.jobs or (os.cpu_count() or 1) started = time.time() From d518608470ea9eb55170f6c8ec0ad314e4dbb227 Mon Sep 17 00:00:00 2001 From: Waffle3z Date: Thu, 30 Jul 2026 19:55:19 -0400 Subject: [PATCH 3/3] Check pushes and pull requests again, and update CONTRIBUTING for JSON Removing verify.yml left both remaining workflows triggered only by a push to main, and rebuild.yml is path-filtered to four paths that exclude validate_solution.py. Nothing ran on a pull request, and the checker itself was never checked from either direction. Hence both events: the recent history here is direct pushes rather than pull requests, and those need it as much. None of the conditional logic that made the old workflows worth simplifying comes back. Read-only permissions, no path filter, nothing to install since validate_solution.py is standard library only. The `shell: bash` line is load-bearing: without pipefail a failing check piped through tee reports success. The guide still described steady_states.txt, branches.txt and the 41->5 syntax, so anyone following it would edit files that no longer exist. It now shows both JSON shapes and writes claimeven as a space throughout, which check 0 now requires: the old advice to use a dot existed only because a trailing space is invisible and editors strip it, and a quoted JSON string cannot lose one. Two of its claims were also wrong. Unreachable entries are reported a frontier at a time rather than all at once, so pruning is iterative. And two playable miai do not tie, they cancel, and the next level decides. --- .github/workflows/verify.yml | 35 ++++++++++++++ CONTRIBUTING.md | 88 ++++++++++++++++++++---------------- 2 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..1087424 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,35 @@ +name: Verify contributions + +# Validates the whole solution/ graph before a change lands, on both events so +# that a direct push to main is covered as well as a pull request. +# +# Read-only, which is what makes it safe on a fork's branch. rebuild.yml is +# separate because pushing artifacts back needs write access, and an untrusted +# branch must not run with that. + +on: [push, pull_request] + +permissions: + contents: read + +concurrency: + # A pull request from a branch here raises both events; keying on its number + # puts them in one group so the duplicate is cancelled. + group: verify-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash # for -o pipefail, so a failure piped through tee still fails + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: {python-version: '3.12'} + + - name: Verify the whole graph + run: python solution/validate_solution.py | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fd81ec..c5c32a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,64 +10,72 @@ every Yellow reply or it does not. CI runs that check for you. Everything that defines the solution lives in `solution/`. Two files are hand-edited. -**`solution/steady_states.txt`** holds one block of six rows per diagram, top -row first, separated by blank lines: - -``` -....... -....... -.....|. -..=|.+. -2.=!1@- -2.21112 +**`solution/steady_states.json`** is a list of diagrams, each a list of six +rows, top row first: + +```json +[ + [ + " | ", + " ", + " | | ", + " =| + ", + "2 =!1@-", + "2 21112" + ] +] ``` - Exactly six rows of exactly seven characters. -- `!` urgent, `@` miai, `|` claimodd, `.` claimeven, `+` plus, `=` equal, +- `!` urgent, `@` miai, `|` claimodd, a space claimeven, `+` plus, `=` equal, `-` minus; `1` is a Red stone and `2` a Yellow stone. - The stones say which board a block belongs to, so it carries no separate identifier. - A `|` means different things on different rows. On an odd row it is a claimodd and can be played. On an even row it can never be played, and its - only effect is to stop that cell being a claimeven. `.` is the mirror of + only effect is to stop that cell being a claimeven. A space is the mirror of this, playable on an even row and silent on an odd one, so neither character is a blank. - A run of bars therefore marks the whole column as claimodd, each odd-row bar becoming playable as the column fills to that height, with the even-row bars between them suppressing the claimevens. Most such columns hold several claimodds rather than one. -- Write claimeven as `.`, not a space. A trailing space is invisible and gets - stripped by editors, which would silently shorten a row. +- A claimeven is a space, and the quoting keeps trailing ones intact, so count + the characters rather than trusting the eye: `"2 "` is a full row. - Only one of each mirror-equivalent pair is stored. The other orientation is re-derived when a representation is rendered, so do not add both. -**`solution/branches.txt`** holds one line per non-leaf Red-to-move node, -giving the single column Red commits to there: +**`solution/branches.json`** maps each non-leaf Red-to-move node to the single +column Red commits to there. The empty string is the empty board: -``` -41->5 -4153->5 +```json +{ + "": "4", + "41": "5", + "4153": "5" +} ``` -The two files interact. A node that gains a diagram stops being a -non-leaf node, so its line comes out of `branches.txt`. Anything that was only reachable -through it comes out too, both its branch lines and its diagrams. The validator -rejects entries it cannot reach, so a contribution that only adds is usually -incomplete. +The two files interact. A node that gains a diagram stops being a non-leaf +node, so its entry comes out of `branches.json`. Anything that was only +reachable through it comes out too, both its branches and its diagrams. The +validator rejects entries it cannot reach, so a contribution that only adds is +usually incomplete. It reports the frontier rather than the whole set, so +expect to run it, delete what it names, and run it again until it is quiet. A diagram contribution edits those two files and nothing else. Each subdirectory of `representations/` builds its artifacts from them with a -`render.py`, and CI rejects a pull request that edits `graph.js` or a `.pb`. -The webclient's 3D layout is a separate case: `spread_graph.py` nudges -`representations/webclient/positions.txt` rather than deriving it, so nothing -regenerates it automatically. +`render.py`, and those are rebuilt automatically after a change lands, so a +pull request should leave them alone. The webclient's 3D layout is a separate +case: `spread_graph.py` nudges `representations/webclient/positions.txt` +rather than deriving it, so nothing regenerates it automatically. ## What makes a diagram valid Red's move follows the priority list from the [explanation page](https://2swap.github.io/WeakC4/explanation/): win, block, -`!`, `@` (only when exactly one is playable), `|` on an odd row or `.` on an -even row, `+`, `=`, `-`. Red must win against *every* legal Yellow +`!`, `@` (only when exactly one is playable), `|` on an odd row or a space on +an even row, `+`, `=`, `-`. Red must win against *every* legal Yellow continuation; a draw is not enough. Two consequences of the site's guarantee that "there is always precisely one @@ -75,7 +83,9 @@ unique move suggested by this priority list": - **A tie between two markers at the same priority level is a failure**, not a coin flip. This has nothing to do with a drawn game. Two playable cells at - the applicable level means the diagram is rejected. Note that the viewer does + the applicable level means the diagram is rejected, with one exception: two + playable `@` do not tie, they cancel, and the next level decides instead. + Note that the viewer does not enforce this, since it only has to play a move and takes the leftmost of a tie, so watching the site play an ambiguous diagram will not reveal that it is ambiguous. Nothing there is checking. @@ -96,14 +106,16 @@ subtree below a move is its own certificate. ## Checking before you open a pull request -The checker needs only the standard library: +CI runs this on your pull request, but running it first is quicker than +waiting. It needs only the standard library: ```bash python solution/validate_solution.py ``` -It validates the whole solution in a few seconds. To see the shape of what you -have changed: +It validates the whole solution in a few seconds and prints a table of the +nine checks, with the failing entries listed underneath. To see the shape of +what you have changed: ```bash python solution/print_statistics.py @@ -111,10 +123,10 @@ python solution/print_statistics.py ## What CI does -Every push and pull request runs the same whole-solution check, and the result -appears in the **Summary** panel of the workflow run, linked from the Checks -tab. After a change lands on `main`, a second workflow re-renders whichever -representations depend on what changed and commits them. +Every push and pull request runs the whole-solution check, and the result +appears in the **Summary** panel of the run, linked from the Checks tab. After +a change lands on `main`, a second workflow re-renders the representations and +commits whatever changed, and a third publishes the site. A first-time contribution needs a maintainer to approve the run, so an empty Checks tab at first is normal.