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
This commit is contained in:
karti-ai
2026-08-28 15:39:03 -07:00
parent 5a9ff8dda9
commit a56f097f28
54 changed files with 8201 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Circle, Pause, Play, RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatDate, usePrefersReducedMotion } from './format';
/** `instant` is not "very fast": it is "do not animate, show me the end". */
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
/** Wall-clock dwell on a step at 1x. Not the model's real latency — see below. */
const BASE_STEP_MS = 1800;
export interface UseTracePlaybackOptions {
stepCount: number;
step: number;
onStepChange: (next: number) => void;
/**
* Dwell time for one step at 1x, in ms. Defaults to a fixed cadence rather
* than the recorded `durationMs`, and that is deliberate: real calls run from
* 300 ms to half a minute, so replaying at true latency produces a player
* that appears frozen. The recorded latency is still shown, verbatim, in the
* model-call panel — it is reported, just not used as a timeline.
*/
stepDurationMs?: (index: number) => number;
initialSpeed?: PlaybackSpeed;
}
export interface TracePlayback {
playing: boolean;
speed: PlaybackSpeed;
setPlaying: (playing: boolean) => void;
setSpeed: (speed: PlaybackSpeed) => void;
toggle: () => void;
restart: () => void;
atEnd: boolean;
}
export function useTracePlayback({
stepCount,
step,
onStepChange,
stepDurationMs,
initialSpeed = 1,
}: UseTracePlaybackOptions): TracePlayback {
const [playing, setPlaying] = useState(false);
const [speed, setSpeedState] = useState<PlaybackSpeed>(initialSpeed);
const atEnd = step >= stepCount - 1;
// The callback identity changes on every render of the shell; holding it in a
// ref keeps it out of the timer effect's deps, or the timer restarts on every
// render and the step never lands.
const onStepChangeRef = useRef(onStepChange);
onStepChangeRef.current = onStepChange;
const setSpeed = useCallback(
(next: PlaybackSpeed) => {
setSpeedState(next);
if (next === 'instant') {
setPlaying(false);
onStepChangeRef.current(Math.max(stepCount - 1, 0));
}
},
[stepCount],
);
const restart = useCallback(() => {
onStepChangeRef.current(0);
setPlaying(stepCount > 1);
}, [stepCount]);
const toggle = useCallback(() => {
if (stepCount <= 1) return;
setPlaying((was) => {
if (was) return false;
// Pressing play at the end replays from the top rather than doing
// nothing, which is what every visitor expects and nobody says out loud.
if (step >= stepCount - 1) onStepChangeRef.current(0);
return true;
});
}, [step, stepCount]);
useEffect(() => {
if (!playing || speed === 'instant' || stepCount <= 1) return;
if (step >= stepCount - 1) {
setPlaying(false);
return;
}
const base = stepDurationMs?.(step) ?? BASE_STEP_MS;
const timer = window.setTimeout(() => {
onStepChangeRef.current(step + 1);
}, Math.max(base / speed, 120));
return () => window.clearTimeout(timer);
}, [playing, speed, step, stepCount, stepDurationMs]);
return { playing, speed, setPlaying, setSpeed, toggle, restart, atEnd };
}
export interface TracePlayerProps {
playing: boolean;
onPlayingChange: (playing: boolean) => void;
speed: PlaybackSpeed;
onSpeedChange: (speed: PlaybackSpeed) => void;
onRestart: () => void;
step: number;
stepCount: number;
onStepChange: (next: number) => void;
/** Straight off the run: never a marketing name for the model. */
model: string;
capturedAt: string;
/** Present only on an `intervened` run; the contract requires it there. */
intervention?: string;
className?: string;
}
/**
* Transport controls for a recorded rollout.
*
* There is no spinner anywhere in this component and there never should be. A
* spinner implies a request is in flight; nothing here is live, and an exec who
* believes they are watching a model think in real time has been misled by the
* UI rather than the copy. Hence the permanent badge — it is not a disclosure
* we tuck into a footnote, it sits in the transport bar for the whole session.
*/
export function TracePlayer({
playing,
onPlayingChange,
speed,
onSpeedChange,
onRestart,
step,
stepCount,
onStepChange,
model,
capturedAt,
intervention,
className,
}: TracePlayerProps) {
const reducedMotion = usePrefersReducedMotion();
const canPlay = stepCount > 1;
return (
<div
className={cn(
'card flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4',
className,
)}
>
<div className="flex items-center gap-1">
<button
type="button"
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
onClick={() => onStepChange(Math.max(step - 1, 0))}
disabled={step <= 0}
aria-label="Previous step"
>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="tap grid place-items-center rounded-lg bg-primary px-4 text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90 disabled:opacity-40"
onClick={() => onPlayingChange(!playing)}
disabled={!canPlay}
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
aria-keyshortcuts="Space"
>
{playing ? (
<Pause className="h-5 w-5" aria-hidden="true" />
) : (
<Play className="h-5 w-5" aria-hidden="true" />
)}
</button>
<button
type="button"
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
onClick={() => onStepChange(Math.min(step + 1, Math.max(stepCount - 1, 0)))}
disabled={step >= stepCount - 1}
aria-label="Next step"
>
<ChevronRight className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
onClick={onRestart}
aria-label="Restart from the first step"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<p className="nums text-sm text-muted">
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
{stepCount}
</p>
<div
role="radiogroup"
aria-label="Playback speed"
className="flex items-center gap-0.5 rounded-lg bg-surface-2 p-0.5"
>
{PLAYBACK_SPEEDS.map((option) => {
const selected = option === speed;
return (
<button
key={String(option)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onSpeedChange(option)}
className={cn(
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
selected
? 'bg-surface text-fg shadow-sm'
: 'text-muted hover:text-fg',
)}
>
{option === 'instant' ? 'Instant' : `${option}x`}
</button>
);
})}
</div>
<RecordedBadge model={model} capturedAt={capturedAt} intervention={intervention} />
{reducedMotion ? (
// Not an apology — a statement that the page is behaving as asked. The
// steps still advance; only the tile flips and slides are gone.
<p className="sr-only">
Reduced motion is on. Steps still advance and every change is announced.
</p>
) : null}
</div>
);
}
export interface RecordedBadgeProps {
model: string;
capturedAt: string;
intervention?: string;
className?: string;
}
export function RecordedBadge({
model,
capturedAt,
intervention,
className,
}: RecordedBadgeProps) {
return (
<p
className={cn(
'ml-auto flex flex-wrap items-center gap-x-1.5 gap-y-1 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs text-muted',
className,
)}
>
<Circle className="h-2 w-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
<span className="font-medium text-fg">Recorded run</span>
<span aria-hidden="true">·</span>
<span className="nums font-mono">{model}</span>
<span aria-hidden="true">·</span>
<span className="nums">{formatDate(capturedAt)}</span>
{intervention ? (
<span className="rounded-md bg-accent-subtle px-1.5 py-0.5 font-medium text-accent-fg">
{intervention}
</span>
) : null}
</p>
);
}