Scaffold: Vite + React 19 + Tailwind 3, PIG's token layer, the demo contract

Foundation for a gallery of RL-environment demos. Three decisions worth
recording here rather than in a commit nobody reads:

The word lists are built, not copied. `envs/wordle_five/words/build_words.py`
intersects Wordnik (MIT, 11,846 five-letter words) with SCOWL's common-American
tier to produce 4,603 answers. The intersection is the point: the list is
derived from two permissive sources by a stated rule rather than copied from
anyone's editorial selection, and both inputs are committed so a rebuild is
byte-identical.

The design tokens are PIG's, inlined as literals. PIG writes its accent onto
the root at runtime because a user picks it; this site has no such choice, so
the runtime theme layer would be a moving part buying nothing. Board tiles get
their own named tokens with measured contrast ratios, because the board is the
one place where colour carries meaning.

pnpm 11 no longer reads the "pnpm" field in package.json. Settings live in
pnpm-workspace.yaml, and an unapproved build script makes `pnpm install` exit 1
rather than warn — so this would have failed CI on a clean checkout, not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:28:57 -07:00
commit 5a9ff8dda9
19 changed files with 207563 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
"""The game itself: pure, seeded, and with no dependency on verifiers.
This module is the reference implementation. `src/demos/wordle/engine.ts` is a
port of it, and CI proves the two agree by scoring every (guess, answer) pair
in the answer list through both and comparing a SHA-256 of the result. If you
change anything here, that hash changes and the TypeScript must change with it.
"""
from __future__ import annotations
import hashlib
import json
import random
from collections import Counter
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
WORD_LENGTH = 5
MAX_GUESSES = 6
GREEN, YELLOW, GREY = "G", "Y", "X"
_WORDS = Path(__file__).parent.parent / "words"
@lru_cache(maxsize=1)
def answers() -> tuple[str, ...]:
return tuple(json.loads((_WORDS / "answers.json").read_text()))
@lru_cache(maxsize=1)
def guesses() -> frozenset[str]:
return frozenset(json.loads((_WORDS / "guesses.json").read_text()))
def score_guess(guess: str, answer: str) -> str:
"""Green/yellow/grey feedback, as two passes.
The two passes are not a stylistic choice. A letter may be marked non-grey
at most as many times as it occurs in the answer, and greens have first
claim on that allocation — so every green in the whole word must be
resolved before any yellow is assigned. Doing it in one pass marks the
first `SASSY` S yellow when `BASIS` has already spent both its S's on the
greens that follow, which is the single most common bug in Wordle
implementations. It is the bug that put a correction video on the most
watched explanation of this game ever made.
"""
guess, answer = guess.lower(), answer.lower()
if len(guess) != len(answer):
raise ValueError(f"length mismatch: {guess!r} vs {answer!r}")
n = len(answer)
pattern = [GREY] * n
remaining: Counter[str] = Counter()
# Pass 1 — greens claim their letters out of the pool.
for i in range(n):
if guess[i] == answer[i]:
pattern[i] = GREEN
else:
remaining[answer[i]] += 1
# Pass 2 — yellows take only what pass 1 left, left to right.
for i in range(n):
if pattern[i] is GREEN or pattern[i] == GREEN:
continue
if remaining[guess[i]] > 0:
pattern[i] = YELLOW
remaining[guess[i]] -= 1
return "".join(pattern)
def is_consistent(candidate: str, guess: str, pattern: str) -> bool:
"""Would `candidate` have produced `pattern` for `guess`?
This is the whole of constraint filtering, and it is also how the
`consistency` reward decides whether a guess contradicts what the player
was already told: a guess is consistent iff it is still a viable answer
given every previous piece of feedback.
"""
return score_guess(guess, candidate) == pattern
def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str | None:
"""NYT hard-mode legality, or None if the guess is legal.
Three details are routinely got wrong and are deliberate here: greens are
positional and locked; yellows are counted, not merely present, so a guess
must carry at least as many copies as were revealed; and grey letters are
NOT banned — hard mode places no constraint at all on known-absent letters.
"""
guess, prev_guess = guess.lower(), prev_guess.lower()
for i, tile in enumerate(prev_pattern):
if tile == GREEN and guess[i] != prev_guess[i]:
return f"{prev_guess[i].upper()} must stay in position {i + 1}"
need = Counter(prev_guess[i] for i, t in enumerate(prev_pattern) if t in (GREEN, YELLOW))
have = Counter(guess)
for letter, count in need.items():
if have[letter] < count:
plural = "" if count == 1 else f" {count} copies of"
return f"guess must contain{plural} {letter.upper()}"
return None
def answer_for_seed(seed: int) -> str:
"""The hidden word for a seed.
Seeded from a dedicated Random rather than the module-global one, because
TextArena seeds the process-global RNG and anything sharing it becomes
order-dependent under concurrency.
"""
pool = answers()
return pool[random.Random(seed).randrange(len(pool))]
@dataclass
class Game:
"""One episode. Deterministic in `seed`, and never raises on bad input."""
seed: int
answer: str = ""
max_guesses: int = MAX_GUESSES
hard_mode: bool = False
history: list[tuple[str, str]] = field(default_factory=list)
rejected: int = 0
def __post_init__(self) -> None:
if not self.answer:
self.answer = answer_for_seed(self.seed)
@property
def solved(self) -> bool:
return bool(self.history) and self.history[-1][1] == GREEN * WORD_LENGTH
@property
def over(self) -> bool:
return self.solved or len(self.history) >= self.max_guesses
def rejection_reason(self, word: str) -> str | None:
"""Why this guess would not be accepted, or None if it is playable."""
word = word.lower().strip()
if len(word) != WORD_LENGTH:
return f"'{word}' is not {WORD_LENGTH} letters"
if word not in guesses():
return f"'{word}' is not in the word list"
if any(word == prev for prev, _ in self.history):
return f"'{word}' has already been guessed"
if self.hard_mode and self.history:
prev, pattern = self.history[-1]
violation = hard_mode_violation(word, prev, pattern)
if violation:
return violation
return None
def play(self, word: str) -> tuple[str | None, str | None]:
"""Play a guess. Returns (pattern, rejection) — exactly one is None.
A rejected guess costs a turn of the model's patience but not a row of
the board, which is how the real game behaves and what keeps a
malformed reply from silently ending the episode.
"""
if self.over:
return None, "the game is already over"
reason = self.rejection_reason(word)
if reason:
self.rejected += 1
return None, reason
word = word.lower().strip()
pattern = score_guess(word, self.answer)
self.history.append((word, pattern))
return pattern, None
def conformance_digest() -> str:
"""SHA-256 over every (guess, answer) pair in the answer list.
5,3xx,xxx patterns hashed in a fixed order. This is the cross-language
gate: the TypeScript port computes the same digest over the same pairs, and
CI fails if they differ. It is strictly stronger than a hand-picked vector
file, which only ever catches the cases somebody thought of.
"""
pool = answers()
digest = hashlib.sha256()
for answer in pool:
digest.update("".join(score_guess(g, answer) for g in pool).encode())
return digest.hexdigest()