Make the page playable: interactive board, live solver, and a usable run picker

Three things the build was quietly missing.

The interactive board was never mounted. The contract has `interactive.init` and
`interactive.Controls`, the demo implemented both, and the shell's `split-play`
beat rendered only the replay — so the beat titled "you and the model get the
same word" showed one board. PlayYourself now renders the visitor's attempt from
the same seed as the run beside it, generically: it knows only the contract, so
any demo shipping an interactive mode gets it and one that does not renders
nothing rather than an empty pane.

solver.worker.ts was dead code — nothing constructed it, which is how CI caught
it: `new Worker(` appeared nowhere in the bundle. It is wired now behind "what
would the best player guess?", and it answers in 92ms from a real worker on
boards no recording covers. That is the difference between a demo and a video.
It also surfaces the moment the solver picks a word that CANNOT win, which is
the counterweight visible in one line instead of explained in a paragraph.

The CI check that found it was itself wrong: it grepped every bundled file for
`blob:`, which React's own code contains in a scheme check, so it failed on a
risk that was not present. It now greps for worker construction from a blob,
which is the thing production CSP actually blocks in silence.

And the run switcher was thirty buttons carrying four distinct labels. Split
into arm and seed, holding the seed across an arm change — comparing two agents
means comparing them on the same hidden word, and silently jumping seeds would
break that while looking fine.

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 16:40:12 -07:00
parent 536c67a9f6
commit 331b46b114
7 changed files with 348 additions and 19 deletions
+13 -4
View File
@@ -51,11 +51,20 @@ jobs:
# A blob-backed worker is blocked in production and nowhere else: the
# site's CSP has no worker-src, so it falls back to default-src 'self'.
# The failure is silent — the solver simply never boots.
- name: no inline workers
# The failure is silent — the worker simply never boots.
#
# Grep for worker construction FROM a blob, not for the string `blob:`
# anywhere. React's own bundle contains that string in a URL-scheme check,
# so the broad version fails on a risk that is not present, which teaches
# everyone to ignore it.
- name: no blob-backed workers
run: |
! grep -rqE "createObjectURL|blob:" dist/assets/*.js \
|| { echo "a blob: URL reached the bundle; CSP will block it in prod"; exit 1; }
if grep -rEo "new (Shared)?Worker\([^)]{0,80}" dist/assets/*.js \
| grep -E "blob:|createObjectURL"; then
echo "a worker is constructed from a blob URL; production CSP blocks it silently"
exit 1
fi
echo "ok — no blob-backed worker construction in the bundle"
python:
runs-on: ubuntu-latest
+2 -1
View File
@@ -17,6 +17,7 @@ ARMS = {
"base-off": ("Out of the box", "recorded"),
"base-on": ("Allowed to think", "intervened"),
"solver": ("Best-known play", "generated"),
"cautious": ("Never wastes a guess", "generated"),
}
# What was done to the run, for arms that had something done to them. Required
@@ -26,7 +27,7 @@ INTERVENTIONS = {
"base-on": "Same model, same seeds, sampled with thinking enabled. No training, no fine-tuning.",
}
ORDER = ["base-off", "base-on", "solver"]
ORDER = ["base-off", "base-on", "solver", "cautious"]
def main() -> int:
+52 -1
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
@@ -47,6 +48,7 @@ ARMS = {
"base-off": {"label": "Out of the box", "thinking": False},
"base-on": {"label": "Allowed to think", "thinking": True},
"solver": {"label": "Best-known play", "thinking": None},
"cautious": {"label": "Never wastes a guess", "thinking": None},
}
@@ -105,6 +107,53 @@ def call_model(messages: list[dict], thinking: bool) -> dict:
}
def cautious_turn(game: Game) -> dict:
"""Only ever guesses a word that could still be the answer.
This arm exists because the reward's counterweight is a claim about a
trade-off, and a trade-off with only one policy on the board is a claim
nobody can check. It never spends a turn on a word that cannot win, so it
takes `consistency` outright — and it pays for that in turns, because it
cannot buy information with a guess that has no chance. Weight the reward
one way and it beats the entropy solver; weight it the other and it loses.
That is the whole argument, made with two recorded runs instead of a claim.
"""
pool = answers()
alive = [w for w in S.consistent_candidates(game.history) if w not in {g for g, _ in game.history}]
if not alive:
guess = "tares"
else:
# Most informative CANDIDATE, not first alphabetically. Same objective as
# the solver, restricted action set — which is the honest contrast. A
# policy that opens on whatever sorts first looks incompetent rather than
# cautious, and would make the trade-off it exists to demonstrate look
# like a straw man.
index = np.array([pool.index(w) for w in alive])
best, best_bits = alive[0], -1.0
for word in alive:
counts: dict[int, int] = {}
row = S.pattern_matrix()[pool.index(word)]
for j in index:
code = int(row[j])
counts[code] = counts.get(code, 0) + 1
total = len(alive)
bits = -sum((c / total) * math.log2(c / total) for c in counts.values())
if bits > best_bits:
best, best_bits = word, bits
guess = best
return {
"reply": f"[{guess}]",
"reasoning": None,
"call": {
"promptTokens": None,
"completionTokens": None,
"reasoningTokens": None,
"durationMs": None,
"finishReason": "generated",
},
}
def solver_turn(game: Game) -> dict:
"""The reference player, recorded in the same shape as a model turn.
@@ -145,6 +194,8 @@ def capture(arm: str, seed: int) -> dict:
while not game.over and len(turns) < MAX_GUESSES * 2:
if arm == "solver":
result = solver_turn(game)
elif arm == "cautious":
result = cautious_turn(game)
else:
result = call_model(messages, bool(config["thinking"]))
@@ -183,7 +234,7 @@ def capture(arm: str, seed: int) -> dict:
return {
"runId": f"{arm}-s{seed}",
"seed": seed,
"model": MODEL if arm != "solver" else "entropy-solver",
"model": {"solver": "entropy-solver", "cautious": "candidate-only-solver"}.get(arm, MODEL),
"capturedAt": time.strftime("%Y-%m-%d"),
"rewards": score(episode),
"metrics": metrics(episode),
+71 -8
View File
@@ -19,6 +19,7 @@ import { EnvAnatomy } from './EnvAnatomy';
import { LimitsCallout } from './LimitsCallout';
import { MetricMover } from './MetricMover';
import { ModelCallPanel } from './ModelCallPanel';
import { PlayYourself } from './PlayYourself';
import { ProvenanceCard } from './ProvenanceCard';
import { ReasoningDrawer } from './ReasoningDrawer';
import { ReasoningPanel } from './ReasoningPanel';
@@ -351,6 +352,19 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
case 'split-play':
return (
<div className="space-y-3">
{demo.interactive ? (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-start">
<PlayYourself demo={demo} seed={run.seed} />
<div className="space-y-2">
<h3 className={st.h3}>What the model did</h3>
<p className={cn(st.prose, 'text-sm')}>
Same hidden answer, same rules, same budget. Its attempt is
below, replayed at the speed it actually happened.
</p>
</div>
</div>
) : null}
{runs.length > 1 ? (
<RunSwitcher
runs={runs}
@@ -569,22 +583,71 @@ function RunSwitcher({
activeId: string;
onSelect: (id: string) => void;
}) {
// Two axes, not one list. With four arms over eight seeds a flat control is
// thirty buttons carrying four distinct labels, which reads as a bug. Arms
// are grouped by `label` because that is what an arm IS in the manifest —
// the shell has no other notion of one, and inventing a field for it would
// put demo-specific structure into the contract.
const arms = useMemo(() => {
const byLabel = new Map<string, RunRef[]>();
for (const run of runs) {
const list = byLabel.get(run.label);
if (list) list.push(run);
else byLabel.set(run.label, [run]);
}
return [...byLabel.entries()].map(([label, group]) => ({ label, runs: group }));
}, [runs]);
const active = runs.find((r) => r.id === activeId) ?? runs[0];
if (!active) return null;
const activeArm = arms.find((a) => a.label === active.label) ?? arms[0];
if (!activeArm) return null;
const pickArm = (label: string) => {
const arm = arms.find((a) => a.label === label);
if (!arm) return;
// Hold the seed across an arm change where the arm has it. Comparing two
// agents means comparing them on the SAME hidden word; silently jumping to
// a different seed would make the comparison meaningless while looking fine.
const sameSeed = arm.runs.find((r) => r.seed === active.seed);
onSelect((sameSeed ?? arm.runs[0])!.id);
};
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<SegmentedControl
label="Recorded run"
options={runs.map((run) => ({
value: run.id,
label: run.label,
...(run.intervention ? { title: run.intervention } : {}),
}))}
value={activeId}
onChange={onSelect}
label="Agent"
options={arms.map((arm) => {
const intervention = arm.runs.find((r) => r.intervention)?.intervention;
return {
value: arm.label,
label: arm.label,
...(intervention ? { title: intervention } : {}),
};
})}
value={activeArm.label}
onChange={pickArm}
className="border border-border p-1"
optionClassName="tap px-3 text-sm"
/>
{activeArm.runs.length > 1 ? (
<SegmentedControl
label="Hidden word"
options={activeArm.runs.map((run) => ({
value: run.id,
label: `#${run.seed}`,
}))}
value={active.id}
onChange={onSelect}
className="border border-border p-1"
optionClassName="tap px-2.5 text-xs nums"
/>
) : null}
</div>
);
}
/**
* The loading state. Shaped like the page it becomes, and with no spinner: a
* spinner here would imply a live model call, which is the one thing the whole
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useMemo, useState } from 'react';
import { RotateCcw } from 'lucide-react';
import type { DemoModule } from '@/lib/demo-kit';
import { cn } from '@/lib/utils';
import * as st from '@/content/styles';
/**
* The visitor's own attempt, on the same seed as the run beside it.
*
* Generic over the demo: it knows only `interactive.init`, `interactive.Controls`
* and `Surface` from the contract, so a demo that ships an interactive mode gets
* this for free and one that does not renders nothing rather than a broken pane.
*
* The seed is the shared coordinate. Both boards derive from it, which is what
* makes "you and the model got the same word" a fact about the page rather than
* a sentence on it.
*/
export function PlayYourself<TState>({
demo,
seed,
className,
}: {
demo: DemoModule<TState>;
seed: number;
className?: string;
}) {
const interactive = demo.interactive;
const Surface = demo.Surface;
// `init` is pure and seeded, so remounting on a seed change is the whole of
// "start a new game" — there is no other state to reset.
const initial = useMemo(
() => (interactive ? interactive.init(seed) : null),
[interactive, seed],
);
const [state, setState] = useState<TState | null>(initial);
const [generation, setGeneration] = useState(0);
useEffect(() => setState(initial), [initial]);
if (!interactive || state === null) return null;
const Controls = interactive.Controls;
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-baseline justify-between gap-3">
<h3 className={st.h3}>Your attempt</h3>
<button
type="button"
onClick={() => {
setState(interactive.init(seed));
setGeneration((g) => g + 1);
}}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted transition-colors duration-1 hover:bg-surface-2 hover:text-fg"
>
<RotateCcw aria-hidden="true" className="size-3.5" />
Start over
</button>
</div>
<div className="card w-fit p-4">
<Surface state={state} />
</div>
{/* Remounting on `generation` clears any state the controls hold of their
own — a half-typed word survives a reset otherwise, and the board and
the keyboard then disagree about what is on the current row. */}
<Controls key={generation} state={state} onChange={setState} seed={seed} />
</div>
);
}
export default PlayYourself;
+41 -1
View File
@@ -3,7 +3,8 @@ import { useEffect, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { play, type BoardState } from './engine';
import { allowedNow, primeGuessList } from './words';
import { useSolver } from './useSolver';
import { allowedNow, ANSWERS, primeGuessList } from './words';
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
@@ -37,6 +38,7 @@ export function Keyboard({
}) {
const states = useMemo(() => letterStates(state), [state]);
const done = state.status !== 'playing';
const solver = useSolver(ANSWERS);
useEffect(() => {
void primeGuessList();
@@ -78,8 +80,46 @@ export function Keyboard({
return () => window.removeEventListener('keydown', handler);
});
const hint = solver.state;
return (
<div className="grid gap-1 sm:gap-1.5" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
{/* The solver, live, on whatever word you picked — not a recording. This
is the difference between a demo and a video: it answers for boards no
rollout on this site ever covered. */}
{hint.status !== 'unavailable' && !done ? (
<div className="mb-1 flex min-h-9 flex-wrap items-center gap-2 text-xs">
<button
type="button"
onClick={() => solver.ask(state.rows)}
disabled={hint.status === 'thinking'}
className="tap inline-flex items-center rounded-md border border-border px-2.5 py-1 font-medium transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-50"
>
{hint.status === 'thinking' ? 'Thinking…' : 'What would the best player guess?'}
</button>
<span aria-live="polite" className="text-muted">
{hint.status === 'ready' ? (
<>
<span className="font-mono font-semibold uppercase text-fg">
{hint.suggestion.guess}
</span>{' '}
<span className="nums">{hint.suggestion.bits.toFixed(2)}</span> bits from{' '}
<span className="nums">{hint.suggestion.candidatesBefore.toLocaleString('en-US')}</span>{' '}
remaining
{hint.suggestion.viable ? null : (
<>
{' '}
·{' '}
<span className="text-warning">
cannot win it is buying information
</span>
</>
)}
</>
) : null}
</span>
</div>
) : null}
{ROWS.map((row, index) => (
<div key={row} className="flex justify-center gap-1 sm:gap-1.5">
{index === 2 ? (
+90
View File
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Pattern } from './engine';
import type { SolverRequest, SolverResponse } from './solver.worker';
/**
* The entropy solver, on a worker, with a main-thread fallback.
*
* This is what lets the page answer for ANY word the visitor picks rather than
* only the handful the recordings cover — the difference between a demo and a
* video. The opening scan is roughly 4,600 x 4,600 pattern computations, which
* visibly freezes a phone if it runs on the main thread at the exact moment
* somebody first touches the board.
*
* `new URL(..., import.meta.url)` gives 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 — in production only. CI greps the bundle for
* exactly that construction.
*/
export interface Suggestion {
guess: string;
bits: number;
viable: boolean;
candidatesBefore: number;
}
export type SolverState =
| { status: 'idle' }
| { status: 'thinking' }
| { status: 'ready'; suggestion: Suggestion }
| { status: 'unavailable' };
export function useSolver(pool: readonly string[]) {
const workerRef = useRef<Worker | null>(null);
const nextId = useRef(0);
const pending = useRef<number | null>(null);
const [state, setState] = useState<SolverState>({ status: 'idle' });
useEffect(() => {
let worker: Worker | null = null;
try {
worker = new Worker(new URL('./solver.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<SolverResponse>) => {
// Ignore everything but the most recent request, or a slow first scan
// lands after a later, faster one and the hint goes backwards.
if (event.data.id !== pending.current) return;
const { guess, bits, viable, candidatesBefore } = event.data;
setState({ status: 'ready', suggestion: { guess, bits, viable, candidatesBefore } });
};
worker.onerror = () => setState({ status: 'unavailable' });
workerRef.current = worker;
} catch {
// Some embedded browsers refuse module workers outright. The button is
// hidden rather than shown broken.
setState({ status: 'unavailable' });
}
return () => {
worker?.terminate();
workerRef.current = null;
};
}, []);
const ask = useCallback(
(history: readonly { guess: string; pattern: Pattern }[]) => {
const worker = workerRef.current;
if (!worker) {
setState({ status: 'unavailable' });
return;
}
const id = (nextId.current += 1);
pending.current = id;
setState({ status: 'thinking' });
const request: SolverRequest = {
id,
pool: pool as string[],
history: history.map((h) => ({ guess: h.guess, pattern: h.pattern })),
};
worker.postMessage(request);
},
[pool],
);
const reset = useCallback(() => {
pending.current = null;
setState({ status: 'idle' });
}, []);
return { state, ask, reset };
}