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:
@@ -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()
|
||||
Reference in New Issue
Block a user