Wordle module, cross-language tests, and the deploy path

The browser engine is a port of the Python one and CI proves it: all 21.2M
(guess, answer) pairs hashed on both sides to the same SHA-256. Six TS tests,
including the duplicate-letter table and the twelve pinned seed vectors that
keep ?seed= permalinks pointing at the same word the recording used.

Word lists are split by how they are used. answers.json is inlined because the
board needs it before first paint to turn a seed into a word, and a fetch there
means a visibly empty board on a cold cache. guesses.json is fetched, because it
is three times larger and only needed the first time somebody presses Enter;
until it lands, validation falls back to the answer list, which accepts strictly
fewer words. The failure mode is 'your real word was briefly rejected', not 'a
non-word was accepted' — the right way round.

The solver runs in a worker constructed from a same-origin module URL, never
Vite's ?worker&inline: that yields a blob:, and production CSP has no
worker-src, so it falls back to default-src 'self' and the worker is blocked
with no console error. It would fail in production only.

deploy.sh smoke-tests the real public hostname from the deploying machine and
fails on a body under 1 kB, because the bind bug's signature is a valid
certificate over an empty 200 and a local --resolve check passes anyway.

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:53:25 -07:00
parent 2c2dcad9fd
commit b601511e7f
38 changed files with 3419 additions and 646 deletions
+120
View File
@@ -0,0 +1,120 @@
/**
* The entropy solver, in the browser.
*
* This is what makes the page feel alive rather than pre-baked: it answers on
* ANY word the visitor picks, including words no recording covers. It mirrors
* `envs/wordle_five/wordle_five/solver.py` in behaviour — same opener, same
* greedy rule, same tie-break — but not in implementation: Python precomputes
* a 21 MB pattern matrix, which is not a thing to ship to a phone.
*
* Run this in a Web Worker. The opening scan is ~4,600 x 4,600 pattern
* computations and will visibly jank the board on the main thread.
*/
import { scoreGuess, type Pattern } from './engine';
/**
* The opening guess, precomputed.
*
* It never depends on the game state, and computing it in the browser would
* cost the full 21M-pair scan on first paint for an answer that is always the
* same. Regenerate with `uv run python -c "from wordle_five.solver import
* _best_opener; from wordle_five.engine import answers; print(answers()[_best_opener()])"`
* — and if the word list changes, this changes with it.
*/
export const OPENER = 'tares';
/** Expected bits of information from playing `guess` against a candidate set. */
export function entropyOf(guess: string, candidates: readonly string[]): number {
const counts = new Map<Pattern, number>();
for (const candidate of candidates) {
const p = scoreGuess(guess, candidate);
counts.set(p, (counts.get(p) ?? 0) + 1);
}
const total = candidates.length;
let bits = 0;
for (const n of counts.values()) {
const probability = n / total;
bits -= probability * Math.log2(probability);
}
return bits;
}
/** Every answer still viable given the feedback so far. */
export function filterCandidates(
pool: readonly string[],
history: readonly { guess: string; pattern: Pattern }[],
): string[] {
let alive = pool as string[];
for (const { guess, pattern } of history) {
alive = alive.filter((word) => scoreGuess(guess, word) === pattern);
}
return alive;
}
export interface Suggestion {
guess: string;
bits: number;
/** True if this guess could itself be the answer. */
viable: boolean;
candidatesBefore: number;
}
/**
* The solver's move.
*
* Ties break toward a guess that could actually win — free expected value, and
* it costs nothing in `consistency`. When it does NOT break that way, the
* solver is buying information with a word that cannot win, which is exactly
* the trade the reward's counterweight prices. The `viable` flag is surfaced
* so the UI can show the moment it happens.
*/
export function suggest(
pool: readonly string[],
history: readonly { guess: string; pattern: Pattern }[],
/** Cap the guesses considered, for responsiveness on a phone. */
budget = 1500,
): Suggestion {
const candidates = filterCandidates(pool, history);
if (history.length === 0) {
return { guess: OPENER, bits: entropyOf(OPENER, pool), viable: pool.includes(OPENER), candidatesBefore: pool.length };
}
if (candidates.length <= 2) {
const guess = candidates[0] ?? OPENER;
return { guess, bits: candidates.length > 1 ? 1 : 0, viable: true, candidatesBefore: candidates.length };
}
// Score every remaining candidate, plus a slice of the wider pool — a
// non-candidate probe is often the better play, and considering only
// candidates would quietly turn this into the candidate-only policy.
const considered = new Set<string>(candidates);
for (const word of pool) {
if (considered.size >= budget) break;
considered.add(word);
}
let best: Suggestion = { guess: candidates[0]!, bits: -1, viable: true, candidatesBefore: candidates.length };
for (const guess of considered) {
const bits = entropyOf(guess, candidates);
const viable = candidates.includes(guess);
if (bits > best.bits + 1e-12 || (Math.abs(bits - best.bits) <= 1e-12 && viable && !best.viable)) {
best = { guess, bits, viable, candidatesBefore: candidates.length };
}
}
return best;
}
/** Play a whole game against a known answer. Used for the reference depth. */
export function solve(pool: readonly string[], answer: string, maxGuesses = 6): string[] {
const history: { guess: string; pattern: Pattern }[] = [];
const played: string[] = [];
for (let turn = 0; turn < maxGuesses; turn += 1) {
const { guess } = suggest(pool, history);
played.push(guess);
const pattern = scoreGuess(guess, answer);
if (pattern === 'GGGGG') return played;
history.push({ guess, pattern });
}
return played;
}