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
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { test } from 'node:test';
import { answerForSeed, fnv1a32, hardModeViolation, scoreGuess } from '../engine';
const ANSWERS: string[] = JSON.parse(
readFileSync(new URL('../../../../envs/wordle_five/words/answers.json', import.meta.url), 'utf8'),
);
// The same table as envs/wordle_five/tests/test_engine.py. Both suites carry it
// because a vector that only exists on one side is a vector that can silently
// stop being checked on the other.
const VECTORS: [string, string, string][] = [
['alloy', 'llama', 'YGYXX'],
['speed', 'erase', 'YXYYX'],
['array', 'radar', 'YYYGX'],
['sassy', 'basis', 'YGGXX'],
['eerie', 'rebel', 'YGYXX'],
['level', 'eagle', 'YYXYX'],
['geese', 'these', 'XXGGG'],
['abbey', 'abbot', 'GGGXX'],
['crane', 'plane', 'XXGGG'],
['alloy', 'balmy', 'YXGXG'],
['tares', 'tares', 'GGGGG'],
];
test('scoring vectors', () => {
for (const [guess, answer, expected] of VECTORS) {
assert.equal(scoreGuess(guess, answer), expected, `${guess}/${answer}`);
}
});
test('a letter is never marked more often than it occurs', () => {
for (const answer of ANSWERS.slice(0, 200)) {
for (const guess of ANSWERS.slice(0, 50)) {
const pattern = scoreGuess(guess, answer);
for (const letter of new Set(guess)) {
const marked = [...guess].filter((c, i) => c === letter && pattern[i] !== 'X').length;
const occurs = [...answer].filter((c) => c === letter).length;
assert.ok(marked <= occurs, `${guess}/${answer} marked ${letter} ${marked}x`);
}
}
}
});
// Pinned identically in envs/wordle_five/tests/test_engine.py. If these two
// lists ever diverge, every ?seed= permalink shows a different puzzle than the
// recorded run it claims to be replaying.
const SEED_VECTORS = [
'wants', 'amber', 'spume', 'toady', 'divot', 'filly',
'bobby', 'clews', 'hikes', 'lawns', 'wreak', 'twist',
];
test('seed vectors match the Python engine', () => {
const got = Array.from({ length: 12 }, (_, s) => answerForSeed(s, ANSWERS));
assert.deepEqual(got, SEED_VECTORS);
});
test('fnv1a32 matches known values', () => {
// Computed by envs/wordle_five/wordle_five/engine.py fnv1a32().
assert.equal(fnv1a32('0'), 0x350ca8af);
assert.equal(fnv1a32('7'), 0x320ca3f6);
});
test('hard mode locks greens and counts yellows, but does not ban greys', () => {
assert.equal(hardModeViolation('crown', 'crane', 'GGXXX'), null);
assert.ok(hardModeViolation('blown', 'crane', 'GGXXX'));
const twoEs = scoreGuess('speed', 'erase'); // YXYYX
assert.equal(hardModeViolation('ester', 'speed', twoEs), null);
assert.ok(hardModeViolation('crest', 'speed', twoEs));
// Grey letters carry no constraint at all — the rule most implementations
// add and the real game does not have.
const cIsGrey = scoreGuess('crane', 'tares');
assert.equal(cIsGrey[0], 'X');
assert.equal(hardModeViolation('stare', 'crane', cIsGrey), null);
});
test('conformance digest matches the Python engine', () => {
const committed = readFileSync(
new URL('../../../../envs/wordle_five/CONFORMANCE.txt', import.meta.url),
'utf8',
).split(/\s+/)[0];
const hash = createHash('sha256');
for (const answer of ANSWERS) {
hash.update(ANSWERS.map((g) => scoreGuess(g, answer)).join(''));
}
assert.equal(hash.digest('hex'), committed);
});
+69
View File
@@ -0,0 +1,69 @@
import type { DemoEpisode, DemoStep } from '@/lib/demo-kit';
import { announceRow, scoreGuess, type BoardState, type Pattern } from './engine';
import { parseGuess } from './parse';
/**
* A recorded episode becomes a list of board snapshots.
*
* Snapshots, not deltas: the scrubber lets you jump to any step, and rebuilding
* state by replaying deltas from zero on every seek is both slower and the kind
* of thing that goes subtly wrong when a step is skipped.
*/
export function adapt(episode: DemoEpisode): DemoStep<BoardState>[] {
const answer = (episode as DemoEpisode & { answer?: string }).answer ?? '';
const rows: { guess: string; pattern: Pattern }[] = [];
let rejected = 0;
return episode.turns.map((turn, index) => {
const guess = parseGuess(turn.reply);
let announce: string;
let caption: string | undefined;
const alreadyPlayed = guess !== null && rows.some((r) => r.guess === guess);
const legal = guess !== null && guess.length === 5 && !alreadyPlayed;
if (!legal) {
rejected += 1;
const why =
guess === null
? 'no guess found in the reply'
: alreadyPlayed
? `repeated ${guess.toUpperCase()}`
: `${guess.toUpperCase()} is not five letters`;
announce = `Turn ${index + 1} refused: ${why}.`;
caption = why;
} else {
const pattern = scoreGuess(guess, answer);
rows.push({ guess, pattern });
announce = announceRow(guess, pattern);
caption =
pattern === 'GGGGG'
? 'solved'
: `${[...pattern].filter((t) => t === 'G').length} placed, ${
[...pattern].filter((t) => t === 'Y').length
} present`;
}
const won = rows.length > 0 && rows[rows.length - 1]!.pattern === 'GGGGG';
return {
index,
state: {
seed: episode.seed,
answer,
rows: rows.map((r) => ({ ...r })),
draft: '',
rejected,
status: won ? 'won' : rows.length >= 6 ? 'lost' : 'playing',
invalid: legal ? null : announce,
hardMode: false,
},
reply: turn.reply,
reasoning: turn.reasoning,
call: turn.call,
announce,
caption,
};
});
}
+82
View File
@@ -0,0 +1,82 @@
import { defineDemo, type DemoEpisode, type RewardValues } from '@/lib/demo-kit';
import { adapt } from './adapter';
import { answerForSeed, emptyBoard, type BoardState } from './engine';
import Keyboard from './keyboard';
import meta from './meta';
import { narrative } from './narrative';
import { parseGuess } from './parse';
import { recompute, reward } from './reward';
import Board from './surface';
import { ANSWERS } from './words';
export default defineDemo<BoardState>({
meta,
narrative,
reward,
anatomy: {
task: 'Find a hidden five-letter word in six guesses.',
actions:
'One five-letter word per turn, from a fixed 11,846-word list. Anything else is refused and costs a turn.',
grader:
'Compares the guess to the answer letter by letter and returns green, yellow or grey. It computes; it does not judge.',
score:
'Half for winning, a third for winning quickly, a fifth for never spending a turn on a word that could not have won.',
},
provenance: {
envPackage: 'wordle_five',
tasksetId: 'wordle-five',
verifiersVersion: '0.3.2.dev12',
command: 'uv run python envs/probe.py',
credits: [
{
label: 'prime-rl — Wordle as a starter example',
href: 'https://github.com/PrimeIntellect-ai/prime-rl/tree/main/examples/basic/wordle',
},
{
label: 'verifiers — the wordle environment',
href: 'https://github.com/PrimeIntellect-ai/verifiers/tree/main/environments/wordle',
},
{
label: 'TextArena — the engine those wrap',
href: 'https://github.com/LeonGuertler/TextArena',
},
{
label: 'Our word lists and how they were built',
href: 'https://github.com/karti-ai/PIG-Demo/blob/main/envs/wordle_five/words/PROVENANCE.md',
},
],
},
adapt,
Surface: Board,
interactive: {
init: (seed: number) => emptyBoard(seed, answerForSeed(seed, ANSWERS)),
Controls: Keyboard,
},
/**
* Re-derive the score from the recorded moves.
*
* This is what the verify badge renders. It deliberately reads
* `reference_depth` off the recorded metrics rather than recomputing it — the
* reference depth comes from a search the browser has no business running,
* and a missing one makes the run *unverifiable* rather than wrong.
*/
verify: (episode: DemoEpisode): RewardValues | null => {
const answer = (episode as DemoEpisode & { answer?: string }).answer;
if (!answer) return null;
const guesses: string[] = [];
let rejected = 0;
for (const turn of episode.turns) {
const guess = parseGuess(turn.reply);
if (guess === null || guess.length !== 5 || guesses.includes(guess)) {
rejected += 1;
continue;
}
guesses.push(guess);
}
const depth = episode.metrics?.['reference_depth'];
return recompute(answer, guesses, rejected, typeof depth === 'number' ? depth : null);
},
});
+239
View File
@@ -0,0 +1,239 @@
/**
* The game, in the browser.
*
* This is a port of `envs/wordle_five/wordle_five/engine.py`, and "port" is
* meant strictly: CI scores every (guess, answer) pair in the answer list
* through both implementations and compares a SHA-256 of the result. If these
* two files ever disagree by one tile, the build fails. That gate is what lets
* the page claim it verified a recorded run rather than merely replayed it.
*
* Keep this module pure and dependency-free. It runs on the main thread, in a
* Web Worker, and under `node --test`.
*/
export const WORD_LENGTH = 5;
export const MAX_GUESSES = 6;
/** A tile: correct position, present elsewhere, or absent. */
export type Tile = 'G' | 'Y' | 'X';
/** Five tiles, as a string. `'GYXXY'`. */
export type Pattern = string;
export const ALL_GREEN: Pattern = 'G'.repeat(WORD_LENGTH);
/**
* Green/yellow/grey feedback, as two passes.
*
* The two passes are not stylistic. 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 word must be resolved before any yellow
* is assigned. A single pass marks the first S of SASSY yellow when BASIS has
* already spent both its S's on the greens that come later.
*
* This is the single most common bug in implementations of this game. It is
* also the bug that put a correction video on the most-watched explanation of
* it ever made, so it is worth the extra loop.
*/
export function scoreGuess(guess: string, answer: string): Pattern {
const g = guess.toLowerCase();
const a = answer.toLowerCase();
if (g.length !== a.length) {
throw new Error(`length mismatch: ${guess} vs ${answer}`);
}
const n = a.length;
const pattern: Tile[] = new Array(n).fill('X');
// Counts of each answer letter still available to yellows, keyed by char
// code so this stays allocation-free in the hot loop the solver runs.
const remaining = new Map<string, number>();
// Pass 1 — greens claim their letters out of the pool.
for (let i = 0; i < n; i += 1) {
if (g[i] === a[i]) {
pattern[i] = 'G';
} else {
const c = a[i]!;
remaining.set(c, (remaining.get(c) ?? 0) + 1);
}
}
// Pass 2 — yellows take only what pass 1 left, left to right.
for (let i = 0; i < n; i += 1) {
if (pattern[i] === 'G') continue;
const c = g[i]!;
const left = remaining.get(c) ?? 0;
if (left > 0) {
pattern[i] = 'Y';
remaining.set(c, left - 1);
}
}
return pattern.join('');
}
/**
* Would `candidate` have produced `pattern` for `guess`?
*
* This is the whole of constraint filtering, and it is also how `consistency`
* decides whether a guess contradicted what the player had already been told:
* a guess is consistent exactly when it was still a possible answer.
*/
export function isConsistent(candidate: string, guess: string, pattern: Pattern): boolean {
return scoreGuess(guess, candidate) === pattern;
}
/**
* Hard-mode legality, or null 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 at all — hard mode places no constraint on known-absent letters.
*/
export function hardModeViolation(
guess: string,
prevGuess: string,
prevPattern: Pattern,
): string | null {
const g = guess.toLowerCase();
const p = prevGuess.toLowerCase();
for (let i = 0; i < prevPattern.length; i += 1) {
if (prevPattern[i] === 'G' && g[i] !== p[i]) {
return `${p[i]!.toUpperCase()} must stay in position ${i + 1}`;
}
}
const need = new Map<string, number>();
for (let i = 0; i < prevPattern.length; i += 1) {
const tile = prevPattern[i];
if (tile === 'G' || tile === 'Y') {
const c = p[i]!;
need.set(c, (need.get(c) ?? 0) + 1);
}
}
const have = new Map<string, number>();
for (const c of g) have.set(c, (have.get(c) ?? 0) + 1);
for (const [letter, count] of need) {
if ((have.get(letter) ?? 0) < count) {
const copies = count === 1 ? '' : ` ${count} copies of`;
return `guess must contain${copies} ${letter.toUpperCase()}`;
}
}
return null;
}
/**
* FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
*
* A language's built-in RNG is not portable: Python's `random.Random(7)` is a
* Mersenne Twister with no honest one-line equivalent here, so seed 7 would
* pick one word in the environment and a different one in this tab. Every
* permalink would then disagree with the recorded run it claims to show. A
* hash sidesteps it — both sides compute the same integer from the same
* string, and there is nothing to keep in step.
*
* `Math.imul` is what makes the 32-bit multiply exact; a plain `*` overflows
* into a double and silently diverges from Python after the first few bytes.
*/
export function fnv1a32(text: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/** The hidden word for a seed. Identical in engine.py — see fnv1a32. */
export function answerForSeed(seed: number, pool: readonly string[]): string {
return pool[fnv1a32(String(seed)) % pool.length]!;
}
/** Why a guess would be refused, or null if it is playable. */
export function rejectionReason(
word: string,
history: readonly [string, Pattern][],
allowed: ReadonlySet<string>,
hardMode = false,
): string | null {
const w = word.toLowerCase().trim();
if (w.length !== WORD_LENGTH) return `'${w}' is not ${WORD_LENGTH} letters`;
if (!allowed.has(w)) return `'${w}' is not in the word list`;
if (history.some(([prev]) => prev === w)) return `'${w}' has already been guessed`;
if (hardMode && history.length > 0) {
const last = history[history.length - 1]!;
return hardModeViolation(w, last[0], last[1]);
}
return null;
}
/** The board, as the surface renders it. Snapshots, never deltas. */
export interface BoardState {
seed: number;
answer: string;
/** Completed rows. */
rows: { guess: string; pattern: Pattern }[];
/** What is being typed into the next row, if the board is interactive. */
draft: string;
/** Replies the game refused. They cost a turn of patience, not a row. */
rejected: number;
status: 'playing' | 'won' | 'lost';
/** Set for one render after an illegal guess, to drive the shake. */
invalid: string | null;
hardMode: boolean;
}
export function emptyBoard(seed: number, answer: string, hardMode = false): BoardState {
return {
seed,
answer,
rows: [],
draft: '',
rejected: 0,
status: 'playing',
invalid: null,
hardMode,
};
}
export function isOver(board: BoardState): boolean {
return board.status !== 'playing';
}
/** Play a guess, returning the next board. Pure — never mutates its input. */
export function play(board: BoardState, word: string, allowed: ReadonlySet<string>): BoardState {
if (isOver(board)) return board;
const history = board.rows.map((r) => [r.guess, r.pattern] as [string, Pattern]);
const reason = rejectionReason(word, history, allowed, board.hardMode);
if (reason) {
return { ...board, rejected: board.rejected + 1, invalid: reason, draft: board.draft };
}
const guess = word.toLowerCase().trim();
const pattern = scoreGuess(guess, board.answer);
const rows = [...board.rows, { guess, pattern }];
const status: BoardState['status'] =
pattern === ALL_GREEN ? 'won' : rows.length >= MAX_GUESSES ? 'lost' : 'playing';
return { ...board, rows, draft: '', invalid: null, status };
}
/**
* What a screen reader hears when a row lands.
*
* Not optional decoration: `prefers-reduced-motion` clamps the tile flip to
* nothing, and colour alone is not a result. This sentence IS the feedback for
* anyone who is not looking at the tiles.
*/
export function announceRow(guess: string, pattern: Pattern, remaining?: number): string {
const parts = [...guess].map((letter, i) => {
const tile = pattern[i];
const state = tile === 'G' ? 'placed' : tile === 'Y' ? 'present' : 'absent';
return `${letter.toUpperCase()} ${state}`;
});
const tail = remaining === undefined ? '' : ` ${remaining} words remain.`;
return `${guess.toUpperCase()}: ${parts.join(', ')}.${tail}`;
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { play, type BoardState } from './engine';
import { allowedNow, primeGuessList } from './words';
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
/** Best-known state of each letter, for tinting the keys. */
function letterStates(board: BoardState): Record<string, 'G' | 'Y' | 'X'> {
const rank = { X: 0, Y: 1, G: 2 } as const;
const out: Record<string, 'G' | 'Y' | 'X'> = {};
for (const { guess, pattern } of board.rows) {
for (let i = 0; i < guess.length; i += 1) {
const letter = guess[i]!;
const tile = pattern[i] as 'G' | 'Y' | 'X';
const current = out[letter];
if (!current || rank[tile] > rank[current]) out[letter] = tile;
}
}
return out;
}
const KEY_TINT: Record<string, string> = {
G: 'bg-tile-exact text-tile-exact-fg',
Y: 'bg-tile-present text-tile-present-fg',
X: 'bg-tile-absent/70 text-tile-absent-fg',
};
export function Keyboard({
state,
onChange,
}: {
state: BoardState;
onChange: (next: BoardState) => void;
}) {
const states = useMemo(() => letterStates(state), [state]);
const done = state.status !== 'playing';
useEffect(() => {
void primeGuessList();
}, []);
const press = (key: string) => {
if (done) return;
if (key === 'enter') {
if (state.draft.length !== 5) {
onChange({ ...state, invalid: 'not enough letters' });
return;
}
onChange(play(state, state.draft, allowedNow()));
return;
}
if (key === 'back') {
onChange({ ...state, draft: state.draft.slice(0, -1), invalid: null });
return;
}
if (state.draft.length < 5) {
onChange({ ...state, draft: state.draft + key, invalid: null });
}
};
// A physical keyboard is how anyone on a laptop will actually play, and
// wiring only the on-screen keys is the most common way that gets forgotten.
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (target && /^(INPUT|TEXTAREA)$/.test(target.tagName)) return;
if (event.key === 'Enter') press('enter');
else if (event.key === 'Backspace') press('back');
else if (/^[a-zA-Z]$/.test(event.key)) press(event.key.toLowerCase());
else return;
event.preventDefault();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
return (
<div className="grid gap-1 sm:gap-1.5" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
{ROWS.map((row, index) => (
<div key={row} className="flex justify-center gap-1 sm:gap-1.5">
{index === 2 ? (
<button
type="button"
onClick={() => press('enter')}
disabled={done}
className="tap flex-[1.6] rounded-md bg-surface-2 px-1 text-[0.65rem] font-semibold uppercase tracking-wide transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-40"
>
Enter
</button>
) : null}
{[...row].map((letter) => (
<button
key={letter}
type="button"
onClick={() => press(letter)}
disabled={done}
aria-label={letter.toUpperCase()}
className={cn(
'tap min-w-0 flex-1 rounded-md text-sm font-semibold uppercase transition-colors duration-1 disabled:opacity-40',
states[letter] ? KEY_TINT[states[letter]!] : 'bg-surface-2 hover:bg-accent-subtle',
)}
>
{letter}
</button>
))}
{index === 2 ? (
<button
type="button"
onClick={() => press('back')}
disabled={done}
aria-label="Backspace"
className="tap flex-[1.6] rounded-md bg-surface-2 px-1 text-[0.65rem] font-semibold uppercase tracking-wide transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-40"
>
Del
</button>
) : null}
</div>
))}
</div>
);
}
export default Keyboard;
+14
View File
@@ -0,0 +1,14 @@
import { defineMeta } from '@/lib/demo-kit';
export default defineMeta({
slug: 'wordle',
title: 'Word Five',
tagline: 'Guess a hidden five-letter word in six tries, from letter-by-letter feedback.',
vertical: 'reference',
status: 'live',
order: 0,
icon: 'Grid3x3',
persona: 'Anyone signing an AI budget',
rewardLine: 'Win fast, minus wasted guesses',
ogImage: '/og/wordle.png',
});
+95
View File
@@ -0,0 +1,95 @@
import type { Narrative } from '@/lib/demo-kit';
/**
* The six beats, in order. The shell renders them; this file decides what the
* page argues and in what sequence.
*/
export const narrative: Narrative = {
thesis:
'This is the smallest complete reinforcement-learning environment we could find that needs no ' +
'domain knowledge at all. It has everything the ones that matter to your business have: a task, ' +
'a fixed set of legal moves, a grader that cannot be argued with, and a score that moves when ' +
'the model gets better. Learn the machine here, and every demo after this is the same machine ' +
'with a different grader.',
anxiety: 'How would we know it was actually working?',
beats: [
{
id: 'hero',
title: 'Their hello-world, not ours',
claim:
'Prime Intellect ship this exact game as a starter environment in three of their public repositories. We did not pick a game. We picked theirs.',
surface: 'hero',
},
{
id: 'anatomy',
title: 'What an environment actually is',
claim:
'Four parts: a task, the moves that are legal, a grader that computes rather than opines, and a number that moves.',
surface: 'anatomy',
},
{
id: 'play',
title: 'You and the model get the same word',
claim:
'Same hidden word, same six guesses, same rules. Play it, then watch what the model did with it.',
surface: 'split-play',
},
{
id: 'watch',
title: 'Watch it think',
claim:
'This is not a video. It is a recorded attempt replayed at the speed it actually happened, and you can step through it one guess at a time.',
surface: 'scrubber',
},
{
id: 'reward',
title: 'You decide what good means',
claim:
'Move one slider and the winner changes. That is not a trick — it is the product.',
surface: 'reward-editor',
},
{
id: 'metric',
title: 'The number that moves',
claim:
'Out of the box, this model solved none of eight. Letting it think first is the cheapest intervention there is, and you can measure exactly what it bought.',
surface: 'metric',
},
{
id: 'receipt',
title: 'The whole environment, in one screen',
claim:
'The grader is thirty lines of Python. Here it is, and here is the command that runs it.',
surface: 'receipt',
},
{
id: 'limits',
title: 'What this does not teach',
claim:
'A word game is missing four things your business has. Each one is why the next demo exists.',
surface: 'limits',
},
],
limits: [
{
text:
'Nobody is pushing back. There is no counterparty adapting to what the agent does, which is most of what makes fraud and abuse hard.',
answeredBy: 'alert-triage',
},
{
text:
'There is no rule the agent could break. No privacy boundary, no regulator, no policy it must satisfy while it optimises.',
answeredBy: 'denial-appeal',
},
{
text:
'Every guess is objectively scorable. Real decisions have partial credit and honest disagreement about what a good answer even was.',
answeredBy: 'coverage-reserve',
},
{
text:
'The score arrives the moment the game ends. A claim, a bid or a dispatch is graded weeks later, by reality.',
answeredBy: 'day-ahead-bid',
},
],
};
+15
View File
@@ -0,0 +1,15 @@
/**
* Pull the move out of a model reply.
*
* Mirrors `envs/wordle_five/wordle_five/protocol.py`: the first bracketed
* alphabetic token, lowercased, and deliberately no length or dictionary check
* — so "guessed a six-letter word" and "produced no guess at all" stay
* different things in the metrics rather than collapsing into one.
*/
const BRACKETED = /\[([A-Za-z]+)\]/;
export function parseGuess(reply: string | null | undefined): string | null {
if (!reply) return null;
const match = BRACKETED.exec(reply);
return match ? match[1]!.toLowerCase() : null;
}
+110
View File
@@ -0,0 +1,110 @@
import type { RewardSpec, RewardValues } from '@/lib/demo-kit';
import rewardSource from '../../../envs/wordle_five/wordle_five/reward.py?raw';
import { scoreGuess, ALL_GREEN } from './engine';
/**
* The reward, mirrored from `envs/wordle_five/wordle_five/reward.py`.
*
* The labels are the point. `consistency` is a fair variable name and a useless
* thing to put in front of somebody deciding a budget; "guesses that could
* still have won" is the same quantity said in a way that needs no gloss.
*/
export const reward: RewardSpec = {
components: [
{
key: 'solved',
label: 'Found the word',
description: 'Did it win, within six guesses.',
weight: 0.5,
role: 'objective',
},
{
key: 'economy',
label: 'Did it in few guesses',
description:
'Turns used, as a ratio against the best player we ship, on the same hidden word. A ratio rather than a count, so a hard draw is not punished as a bad game.',
weight: 0.3,
role: 'objective',
},
{
key: 'consistency',
label: 'Never wasted a turn',
description:
'The share of attempts spent on a word that could still have been the answer. This one pulls against the other two on purpose.',
weight: 0.2,
role: 'counterweight',
},
],
metrics: [
{ key: 'guesses_used', label: 'Guesses used', description: 'Rows filled on the board.' },
{
key: 'rejected_replies',
label: 'Replies refused',
description: 'Not a word, wrong length, or a repeat. Costs a turn, not a row.',
},
{
key: 'inconsistent_guesses',
label: 'Contradicted itself',
description: 'Guesses ruled out by feedback the model had already been given.',
},
{
key: 'reference_depth',
label: 'Reference took',
description: 'How many guesses our best player needed for this same word.',
},
],
source: {
path: 'envs/wordle_five/wordle_five/reward.py',
code: rewardSource,
marker: 'reward',
},
};
const WEIGHTS: Record<string, number> = { solved: 0.5, economy: 0.3, consistency: 0.2 };
/**
* Re-derive the reward from the moves alone.
*
* This is the browser half of the verification: the page does not display the
* numbers the environment handed it, it recomputes them from the recorded
* guesses and shows the difference. `referenceDepth` cannot be recomputed here
* — it comes from a search the browser has no business running — so it is read
* off the recorded metrics, and its absence makes the run unverifiable rather
* than wrong.
*/
export function recompute(
answer: string,
guesses: readonly string[],
rejected: number,
referenceDepth: number | null,
): RewardValues | null {
if (referenceDepth === null) return null;
const patterns = guesses.map((g) => scoreGuess(g, answer));
const won = patterns.length > 0 && patterns[patterns.length - 1] === ALL_GREEN;
const solved = won ? 1 : 0;
const economy = won ? Math.min(1, referenceDepth / Math.max(1, guesses.length)) : 0;
// Scored over turns SPENT, refusals included. Counting only accepted guesses
// would hand a perfect score to a run that played one word and then jammed
// the parser: one guess, no contradictions, nothing to contradict.
const spent = guesses.length + rejected;
let viable = 0;
for (let i = 0; i < guesses.length; i += 1) {
let ok = true;
for (let k = 0; k < i; k += 1) {
if (scoreGuess(guesses[k]!, guesses[i]!) !== patterns[k]) {
ok = false;
break;
}
}
if (ok) viable += 1;
}
const consistency = spent === 0 ? 0 : viable / spent;
return { solved, economy, consistency };
}
export { WEIGHTS };
+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;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* The solver, off the main thread.
*
* The opening scan is ~4,600 x 4,600 pattern computations. On the main thread
* that is a visible freeze on a phone, in the exact moment the visitor first
* touches the board.
*
* Constructed with `new Worker(new URL('./solver.worker.ts', import.meta.url),
* { type: 'module' })`, which produces a same-origin module in the build.
* Never Vite's `?worker&inline`: that yields a blob: URL, and production CSP
* has no `worker-src`, so it falls back to `default-src 'self'` and the worker
* is blocked with no console error at all. The solver would simply never boot,
* in production only.
*/
import type { Pattern } from './engine';
import { suggest } from './solver';
export interface SolverRequest {
id: number;
pool: string[];
history: { guess: string; pattern: Pattern }[];
}
export interface SolverResponse {
id: number;
guess: string;
bits: number;
viable: boolean;
candidatesBefore: number;
}
self.onmessage = (event: MessageEvent<SolverRequest>) => {
const { id, pool, history } = event.data;
const result = suggest(pool, history);
const response: SolverResponse = { id, ...result };
(self as unknown as Worker).postMessage(response);
};
+154
View File
@@ -0,0 +1,154 @@
import { memo } from 'react';
import { cn } from '@/lib/utils';
import { MAX_GUESSES, WORD_LENGTH, type BoardState, type Pattern } from './engine';
/**
* The board. One component for all three jobs — you playing, the replay, and
* the gallery thumbnail — because three near-identical boards is how they drift.
*/
const TILE_CLASS: Record<string, string> = {
G: 'bg-tile-exact text-tile-exact-fg border-tile-exact',
Y: 'bg-tile-present text-tile-present-fg border-tile-present',
X: 'bg-tile-absent text-tile-absent-fg border-tile-absent',
};
/**
* A glyph per state, shown only in high-contrast mode.
*
* Green/yellow/grey is a colour-only distinction, which is exactly why the
* original game ships a high-contrast mode. A second channel means the result
* survives deuteranopia, a projector with the colour balance wrong, and a
* screenshot printed in black and white.
*/
const TILE_GLYPH: Record<string, string> = { G: '●', Y: '◆', X: '' };
function Tile({
letter,
tile,
index,
revealing,
compact,
}: {
letter: string;
tile: Pattern[number] | null;
index: number;
revealing: boolean;
compact?: boolean;
}) {
const filled = letter !== '';
return (
<div
className={cn(
'relative grid place-items-center select-none font-semibold uppercase',
'aspect-square rounded-md border-2 transition-colors',
compact ? 'text-[0.55rem] border' : 'text-xl sm:text-2xl',
tile
? TILE_CLASS[tile]
: filled
? 'border-muted/60 bg-surface text-fg'
: 'border-border bg-surface-2/40 text-fg',
// The flip is a rotation about X with the colour landing at the
// half-way point, staggered along the row. `prefers-reduced-motion`
// clamps it to nothing globally, which is why announceRow() exists.
revealing && tile && 'motion-safe:animate-[tile-flip_520ms_ease-enter_both]',
filled && !tile && 'motion-safe:animate-[tile-pop_120ms_ease-enter]',
)}
style={revealing ? { animationDelay: `${index * 100}ms` } : undefined}
aria-hidden="true"
>
{letter}
{tile ? (
<span className="pointer-events-none absolute bottom-0 right-0.5 text-[0.5em] leading-none opacity-0 [:root[data-contrast='high']_&]:opacity-90">
{TILE_GLYPH[tile]}
</span>
) : null}
</div>
);
}
function Row({
guess,
pattern,
revealing,
shake,
compact,
}: {
guess: string;
pattern: Pattern | null;
revealing: boolean;
shake?: boolean;
compact?: boolean;
}) {
const letters = guess.padEnd(WORD_LENGTH, ' ').slice(0, WORD_LENGTH);
return (
<div
className={cn(
'grid gap-1 sm:gap-1.5',
shake && 'motion-safe:animate-[tile-shake_600ms_ease-enter]',
)}
style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
>
{[...letters].map((letter, i) => (
<Tile
key={i}
letter={letter.trim()}
tile={pattern ? (pattern[i] as Pattern[number]) : null}
index={i}
revealing={revealing}
compact={compact}
/>
))}
</div>
);
}
export const Board = memo(function Board({
state,
compact,
}: {
state: BoardState;
compact?: boolean;
}) {
const rows = state.rows;
const draftRow = rows.length < MAX_GUESSES && state.status === 'playing' ? state.draft : null;
const blanks = MAX_GUESSES - rows.length - (draftRow === null ? 0 : 1);
return (
<div
className={cn('grid gap-1 sm:gap-1.5', compact ? 'w-full max-w-[7rem]' : 'w-full max-w-sm')}
role="img"
aria-label={
rows.length === 0
? 'Empty board, six guesses remaining.'
: `${rows.length} of ${MAX_GUESSES} guesses played.`
}
>
{rows.map((row, i) => (
<Row
key={`${row.guess}-${i}`}
guess={row.guess}
pattern={row.pattern}
revealing={i === rows.length - 1}
compact={compact}
/>
))}
{draftRow !== null ? (
<Row
guess={draftRow}
pattern={null}
revealing={false}
shake={state.invalid !== null}
compact={compact}
/>
) : null}
{Array.from({ length: Math.max(0, blanks) }, (_, i) => (
<Row key={`blank-${i}`} guess="" pattern={null} revealing={false} compact={compact} />
))}
</div>
);
});
export default Board;
+51
View File
@@ -0,0 +1,51 @@
/**
* The two word lists, loaded the way each is actually used.
*
* `answers` is inlined: the board needs it before first paint to turn a seed
* into a hidden word, and a fetch there would mean a visible empty board on a
* cold cache. At 4,603 words it costs about 14 kB gzipped inside this demo's
* lazy chunk, and never touches the entry chunk.
*
* `guesses` is fetched. It is nearly three times larger, and it is only needed
* the first time somebody presses Enter — by which point it has long arrived.
* Until it does, `isAllowed` falls back to the answer list, which accepts
* strictly fewer words: the failure mode is "your real word was rejected for a
* moment", not "a non-word was accepted", and that is the right way round.
*/
import answersJson from '../../../envs/wordle_five/words/answers.json';
export const ANSWERS: readonly string[] = answersJson;
let guesses: Set<string> | null = null;
let inFlight: Promise<Set<string>> | null = null;
/** Kick off the guess-list fetch. Safe to call more than once. */
export function primeGuessList(): Promise<Set<string>> {
if (guesses) return Promise.resolve(guesses);
if (!inFlight) {
inFlight = fetch('/words/guesses.json')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((words: string[]) => {
guesses = new Set(words);
return guesses;
})
.catch(() => {
// Degrade to the answer list rather than blocking play. A demo that
// shows an error card because a 106 kB asset was slow would be a worse
// failure than a briefly stricter dictionary.
guesses = new Set(ANSWERS);
return guesses;
});
}
return inFlight;
}
/** The set currently available for validation. Never null. */
export function allowedNow(): ReadonlySet<string> {
return guesses ?? new Set(ANSWERS);
}
export function guessListReady(): boolean {
return guesses !== null;
}