Files
karti-ai a56f097f28 wordle-five: the engine, the reward, the solver and the probe that checks them
The Python is the source of truth; src/demos/wordle/engine.ts will be a port of
it, and CI gates the two against a SHA-256 over all 21.2M (guess, answer)
pattern pairs rather than a hand-picked vector file — a vector file only ever
catches the cases somebody thought of.

The reward is three weighted components, and the third one is the reason this
demo is worth building. `solved` and `economy` pull toward winning. `consistency`
pulls against them, because a player maximising information deliberately guesses
words that cannot win — a word that splits the remaining candidates evenly
teaches more than a word that might happen to be right. That is good play, and
it costs consistency.

The probe ladder proves the tension is real rather than asserted:

  inaction        0.0000   crude       0.0111   plausible  0.1224
  candidate_only  0.8925   exhaustive  0.9031   oracle     0.9458

The two good policies are 0.05 apart and neither dominates — the entropy oracle
takes 1.00 economy and 0.73 consistency, the candidate-only player takes 0.75
and 1.00. Which one wins is a decision about what you want, which is the whole
argument the site exists to make. probe.py fails CI if either starts dominating.

Two traps found by building it. `consistency` is scored over turns SPENT, not
guesses accepted: counting only legal guesses hands a free 1.0 to a policy that
plays one word and then jams the parser five times — one guess, no
contradictions, perfect score. And `economy`'s denominator is the depth the
SHIPPED solver reaches, not a depth-optimal search: entropy-greedy is not
depth-optimal, so grading it against an exact optimum would make the oracle
rung fail its own assertion on some seeds.

The word lists are built from Wordnik (MIT) intersected with SCOWL, never from
the original game's 2,315 answers. 4,603 answers makes this materially harder
than the original, so the published SALET/3.4212 results are cited as belonging
to that list and our own reference player's TARES/3.72 is measured here.

verifiers is an optional extra. The engine, reward, solver and probe all run —
and gate — without an RL stack resolvable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
2026-08-28 15:39:03 -07:00

229 lines
8.6 KiB
Python

"""The reference player: greedy maximum-entropy with full constraint filtering.
Two jobs, and it is important they are the same code:
1. It is the `oracle` rung of the probe ladder.
2. Its solve depth on a seed is the denominator of the `economy` reward.
Deriving the reward's reference from the shipped policy rather than from a
separately-computed optimum is deliberate. If `economy` were measured against a
depth-optimal search the oracle could not reach 1.0 — entropy-greedy is not
depth-optimal — and the probe's own assertion would fail on some seeds. The
optimum is still published, as a diagnostic, in `optimal_depth_report()`.
Note what this solver does NOT get: it scores 1.0 on solving and on economy,
and strictly below 1.0 on `consistency`, because maximising information means
deliberately guessing words that cannot win. That tension is the point — see
reward.py.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
import numpy as np
from .engine import GREEN, WORD_LENGTH, answers, score_guess
_CACHE = Path(__file__).parent.parent / ".pattern-cache" / "answers.npy"
# Base-3 code for a 5-tile pattern: 0..242, so it fits in a uint8. The all-green
# pattern is the largest code, which is the only value the solver compares
# against by name.
_TILE_VALUE = {"X": 0, "Y": 1, "G": 2}
ALL_GREEN_CODE = sum(2 * 3**i for i in range(WORD_LENGTH))
def pattern_code(pattern: str) -> int:
return sum(_TILE_VALUE[t] * 3**i for i, t in enumerate(pattern))
def _compute_matrix(words: tuple[str, ...]) -> np.ndarray:
"""M[g, a] = the pattern code for guessing words[g] against answer words[a].
Vectorised one letter at a time and chunked over guesses. The naive
(n_guess, n_answer, 5) boolean intermediate is ~106 MB per letter at this
list size, so chunking is what keeps peak memory in the tens of megabytes
instead of gigabytes.
"""
n = len(words)
grid = np.frombuffer("".join(words).encode(), dtype=np.uint8).reshape(n, WORD_LENGTH)
grid = grid - ord("a")
out = np.zeros((n, n), dtype=np.uint8)
place = np.array([3**i for i in range(WORD_LENGTH)], dtype=np.uint16)
chunk = 256
for start in range(0, n, chunk):
stop = min(start + chunk, n)
g = grid[start:stop] # (c, 5)
# Greens first, exactly as engine.py does: every green in the word is
# resolved before any yellow is considered.
green = g[:, None, :] == grid[None, :, :] # (c, n, 5)
tiles = green.astype(np.uint16) * 2
for letter in range(26):
g_is = g == letter # (c, 5)
a_is = grid == letter # (n, 5)
if not g_is.any() or not a_is.any():
continue
# How many of this letter the answer has left after greens took theirs.
green_here = green & g_is[:, None, :] # (c, n, 5)
available = a_is.sum(1)[None, :] - green_here.sum(2) # (c, n)
# Candidate yellow positions, ranked left to right. A position wins a
# yellow only if its rank is inside the remaining allocation, which
# is what makes SASSY/BASIS come out YGGXX rather than YGGYX.
candidate = g_is[:, None, :] & ~green_here # (c, n, 5)
rank = np.cumsum(candidate, axis=2) - 1
yellow = candidate & (rank < available[:, :, None])
tiles += yellow.astype(np.uint16)
out[start:stop] = (tiles * place[None, None, :]).sum(2).astype(np.uint8)
return out
@lru_cache(maxsize=1)
def pattern_matrix() -> np.ndarray:
"""The answer-vs-answer pattern matrix, memoised on disk.
Roughly 21 MB at the shipped list size. Gitignored and regenerated on
demand — committing it would put a build artefact in a repo whose whole
argument is that you can rebuild everything it claims.
"""
words = answers()
if _CACHE.exists():
cached = np.load(_CACHE)
if cached.shape == (len(words), len(words)):
return cached
matrix = _compute_matrix(words)
_CACHE.parent.mkdir(parents=True, exist_ok=True)
np.save(_CACHE, matrix)
return matrix
def _verify_matrix_row(index: int) -> None:
"""Cross-check one matrix row against engine.score_guess. Used by tests."""
words = answers()
row = pattern_matrix()[index]
for j, answer in enumerate(words):
expected = pattern_code(score_guess(words[index], answer))
if row[j] != expected:
raise AssertionError(f"matrix disagrees with engine at ({index}, {j})")
def best_guess(candidates: np.ndarray, first: bool = False) -> int:
"""The index of the guess with the highest expected information gain.
Ties break on being a candidate — among equally informative guesses, prefer
one that could actually win, which is free expected value and also costs
nothing in `consistency`.
"""
matrix = pattern_matrix()
if first:
# The opening guess never depends on the seed, so it is computed once
# per process rather than once per game.
return _best_opener()
if len(candidates) == 1:
return int(candidates[0])
sub = matrix[:, candidates] # (n_guess, n_candidates)
best_index, best_score = -1, (-1.0, -1)
total = len(candidates)
candidate_set = set(candidates.tolist())
for g in range(matrix.shape[0]):
counts = np.bincount(sub[g], minlength=243)
counts = counts[counts > 0]
probability = counts / total
entropy = float(-(probability * np.log2(probability)).sum())
score = (entropy, 1 if g in candidate_set else 0)
if score > best_score:
best_index, best_score = g, score
return best_index
@lru_cache(maxsize=1)
def _best_opener() -> int:
matrix = pattern_matrix()
total = matrix.shape[1]
best_index, best_entropy = -1, -1.0
for g in range(matrix.shape[0]):
counts = np.bincount(matrix[g], minlength=243)
counts = counts[counts > 0]
probability = counts / total
entropy = float(-(probability * np.log2(probability)).sum())
if entropy > best_entropy:
best_index, best_entropy = g, entropy
return best_index
def solve(answer: str, max_guesses: int = 6) -> list[str]:
"""Play a full game against a known answer. Returns the guesses made."""
words = answers()
matrix = pattern_matrix()
target = words.index(answer)
candidates = np.arange(len(words))
played: list[str] = []
for turn in range(max_guesses):
pick = best_guess(candidates, first=(turn == 0))
played.append(words[pick])
if pick == target:
return played
observed = matrix[pick, target]
candidates = candidates[matrix[pick, candidates] == observed]
return played
@lru_cache(maxsize=4096)
def reference_depth(answer: str) -> int:
"""How many guesses the shipped solver needs for this answer.
This is the `economy` denominator. Cached because the probe and the reward
both ask for it on the same seeds.
"""
return len(solve(answer))
def entropy_of(guess: str, candidates: list[str]) -> float:
"""Expected bits gained by playing `guess` against a candidate set."""
words = answers()
matrix = pattern_matrix()
g = words.index(guess)
idx = np.array([words.index(c) for c in candidates])
counts = np.bincount(matrix[g, idx], minlength=243)
counts = counts[counts > 0]
probability = counts / len(candidates)
return float(-(probability * np.log2(probability)).sum())
def consistent_candidates(history: list[tuple[str, str]]) -> list[str]:
"""Every answer still viable given the feedback so far."""
words = answers()
matrix = pattern_matrix()
alive = np.arange(len(words))
for guess, pattern in history:
if guess not in words:
# A guess outside the answer list still constrains, but is not a row
# of this matrix; fall back to the scalar engine for it.
alive = np.array([i for i in alive if score_guess(guess, words[i]) == pattern])
continue
g = words.index(guess)
alive = alive[matrix[g, alive] == pattern_code(pattern)]
return [words[i] for i in alive]
def optimal_depth_report(seeds: list[int]) -> dict[str, float]:
"""Published diagnostics for the shipped solver over a set of seeds."""
from .engine import answer_for_seed
depths = [reference_depth(answer_for_seed(s)) for s in seeds]
return {
"mean_guesses": sum(depths) / len(depths),
"worst_case": max(depths),
"solved_within_six": sum(1 for d in depths if d <= 6) / len(depths),
}