diff --git a/envs/wordle_five/tests/test_engine.py b/envs/wordle_five/tests/test_engine.py index 7f6e61f..196e1b7 100644 --- a/envs/wordle_five/tests/test_engine.py +++ b/envs/wordle_five/tests/test_engine.py @@ -72,6 +72,21 @@ def test_seeded_answer_is_deterministic() -> None: assert answer_for_seed(1) != answer_for_seed(2) +# Pinned so the browser cannot drift. `src/demos/wordle/__tests__/engine.test.ts` +# asserts the same twelve words for the same twelve seeds. If a language's +# built-in RNG were used instead of the shared hash, these two lists would +# differ and every ?seed= permalink would show a different word than the +# recorded run it claims to replay. +SEED_VECTORS = [ + "wants", "amber", "spume", "toady", "divot", "filly", + "bobby", "clews", "hikes", "lawns", "wreak", "twist", +] + + +def test_seed_vectors_match_the_typescript_port() -> None: + assert [answer_for_seed(s) for s in range(12)] == SEED_VECTORS + + def test_rejections_do_not_consume_a_row() -> None: game = Game(seed=3, answer="tares") assert game.play("zzzzz")[1] is not None # not a word diff --git a/envs/wordle_five/wordle_five/engine.py b/envs/wordle_five/wordle_five/engine.py index 6e2dfab..99f8f60 100644 --- a/envs/wordle_five/wordle_five/engine.py +++ b/envs/wordle_five/wordle_five/engine.py @@ -10,7 +10,6 @@ from __future__ import annotations import hashlib import json -import random from collections import Counter from dataclasses import dataclass, field from functools import lru_cache @@ -105,15 +104,27 @@ def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str | return None -def answer_for_seed(seed: int) -> str: - """The hidden word for a seed. +def fnv1a32(text: str) -> int: + """FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly. - Seeded from a dedicated Random rather than the module-global one, because - TextArena seeds the process-global RNG and anything sharing it becomes - order-dependent under concurrency. + A language's built-in RNG is not portable: `random.Random(7)` is a + Mersenne Twister and there is no honest one-line JavaScript equivalent, so + seed 7 would pick one word here and a different one in the browser. Every + permalink on the site would then disagree with the recorded run it claims + to show. A hash sidesteps the whole problem — both sides compute the same + integer from the same string, and there is nothing to keep in step. """ + h = 0x811C9DC5 + for byte in text.encode(): + h ^= byte + h = (h * 0x01000193) & 0xFFFFFFFF + return h + + +def answer_for_seed(seed: int) -> str: + """The hidden word for a seed. Identical in engine.ts — see fnv1a32.""" pool = answers() - return pool[random.Random(seed).randrange(len(pool))] + return pool[fnv1a32(str(seed)) % len(pool)] @dataclass diff --git a/scripts/_lib.mjs b/scripts/_lib.mjs index 1fd4485..eec39d0 100644 --- a/scripts/_lib.mjs +++ b/scripts/_lib.mjs @@ -316,17 +316,43 @@ export function literalAfter(src, anchor, open = '{') { return null; } +/** Marker key on the stand-in an unresolvable identifier evaluates to. */ +export const UNRESOLVED = '__pigUnresolvedIdentifier__'; + +export const isUnresolved = (v) => Boolean(v) && typeof v === 'object' && UNRESOLVED in v; + +/** + * A sandbox in which every free identifier resolves to a labelled stand-in. + * + * `RewardSpec.source.code` is legitimately an identifier — the Python is + * imported with `?raw` and cannot exist in plain Node — so the reward literal + * must be readable WITHOUT its `code`. Strict mode is still the default: a + * weight that turns out to be a stand-in is a contract failure, not a skip. + */ +function lenientSandbox() { + return new Proxy(Object.create(null), { + has: () => true, + get: (_target, key) => { + if (key === Symbol.unscopables) return undefined; + if (typeof key !== 'string') return undefined; + return { [UNRESOLVED]: key }; + }, + }); +} + /** * Evaluates a TypeScript object/array literal as plain data. * * `as const` and `satisfies T` are stripped from code spans only. Anything else * a literal might carry — an identifier, a call, a spread of an import — throws, - * and callers turn that into a contract failure with the file named. + * and callers turn that into a contract failure with the file named. Pass + * `{lenient: true}` to get stand-ins for free identifiers instead. * * @param {string} text * @param {string} label + * @param {{lenient?: boolean}} [options] */ -export function evalLiteral(text, label) { +export function evalLiteral(text, label, options = {}) { const js = segment(text) .map((s) => s.code @@ -338,7 +364,8 @@ export function evalLiteral(text, label) { ) .join(''); try { - const value = vm.runInNewContext(`(${js})`, Object.create(null), { timeout: 2000 }); + const sandbox = options.lenient ? lenientSandbox() : Object.create(null); + const value = vm.runInNewContext(`(${js})`, sandbox, { timeout: 2000 }); return { ok: true, value, error: null }; } catch (error) { return { @@ -421,6 +448,47 @@ export function loadAllMetas() { return { metas, errors }; } +/** Every .ts/.tsx file that belongs to one demo. */ +export function demoFiles(slug) { + return walk(path.join(DEMOS_DIR, slug), (f) => /\.tsx?$/.test(f)); +} + +/** + * Finds one named literal anywhere inside a demo's own source. + * + * A demo is free to put `reward` in `reward.ts` or inline it in `demo.tsx`; + * the contract is about the values, not the file layout. First match in + * filename order wins, and the file it came from is returned so failures can + * name it. + * + * @param {string} slug + * @param {RegExp[]} anchors + * @param {'{' | '['} open + * @param {string} label + * @param {{lenient?: boolean}} [options] + */ +export function findInDemo(slug, anchors, open, label, options = {}) { + for (const file of demoFiles(slug)) { + const src = read(file); + for (const anchor of anchors) { + let text = null; + try { + text = literalAfter(src, anchor, open); + } catch (error) { + return { file: rel(file), error: `could not brace-match the ${label} literal: ${error.message}` }; + } + if (!text) continue; + const result = evalLiteral(text, label, options); + if (!result.ok) return { file: rel(file), error: result.error, text }; + return { file: rel(file), value: result.value, text }; + } + } + return { + file: null, + error: `no ${label} literal found in any .ts/.tsx file under ${rel(path.join(DEMOS_DIR, slug))}`, + }; +} + /* ------------------------------------------------------------------ verticals */ export const VERTICALS_FILE = abs('src', 'content', 'verticals.ts'); diff --git a/src/components/demo/ModelCallPanel.tsx b/src/components/demo/ModelCallPanel.tsx new file mode 100644 index 0000000..ca0f3a4 --- /dev/null +++ b/src/components/demo/ModelCallPanel.tsx @@ -0,0 +1,82 @@ +import type { ModelCall } from '@/lib/demo-kit/types'; +import { cn } from '@/lib/utils'; +import { DASH, formatInt, formatMs, humaniseToken } from './format'; + +export interface ModelCallPanelProps { + call: ModelCall | null; + className?: string; + title?: string; +} + +interface Row { + label: string; + value: string; + hint?: string; +} + +/** + * The model call, straight off the trace. + * + * Nothing here is computed, averaged or estimated. Every field is a value the + * recorder wrote down, and a field the recorder did not write down renders as + * an em dash — never as zero, and never quietly omitted. A provider that does + * not report reasoning tokens is a fact about the trace, and hiding the row + * would turn "we do not know" into "there were none". + */ +export function ModelCallPanel({ call, className, title = 'Model call' }: ModelCallPanelProps) { + const rows: Row[] = [ + { + label: 'finish_reason', + value: call?.finishReason ? humaniseToken(call.finishReason) : DASH, + hint: 'Why the model stopped generating', + }, + { + label: 'Prompt tokens', + value: formatInt(call?.promptTokens ?? null), + hint: 'Everything sent in: system, board, history', + }, + { + label: 'Completion tokens', + value: formatInt(call?.completionTokens ?? null), + hint: 'The visible reply', + }, + { + label: 'Reasoning tokens', + value: formatInt(call?.reasoningTokens ?? null), + hint: 'Billed thinking, when the provider reports it', + }, + { + label: 'Latency', + value: formatMs(call?.durationMs ?? null), + hint: 'Real elapsed time when the run was recorded', + }, + ]; + + return ( +
+
+

{title}

+
+ {call === null ? ( +

+ This step did not involve a model call — it is a state change the environment made on + its own. +

+ ) : ( +
+ {rows.map((row) => ( +
+
+ {row.label} + {row.hint ? ( + {row.hint} + ) : null} +
+
{row.value}
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/demo/ReasoningDrawer.tsx b/src/components/demo/ReasoningDrawer.tsx new file mode 100644 index 0000000..398bd6d --- /dev/null +++ b/src/components/demo/ReasoningDrawer.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Drawer } from 'vaul'; +import { Brain, ChevronUp } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ReasoningPanel } from './ReasoningPanel'; +import type { ReasoningPanelProps } from './ReasoningPanel'; + +const SNAP_POINTS = [0.4, 0.9]; + +export interface ReasoningDrawerProps extends ReasoningPanelProps { + /** Controlled from the shell when it wants the drawer open on a step change. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + triggerClassName?: string; +} + +/** + * The reasoning panel, for a phone. + * + * Below `lg` there is no room for a column beside the board, and putting the + * reasoning under the board means the visitor watches the run with the thinking + * off-screen. A drawer at 40% shows the first few lines without covering the + * board; dragging to 90% is the "let me actually read this" gesture. + * + * Rendering is caller-gated rather than CSS-gated: mounting a vaul drawer on + * desktop and hiding it with `lg:hidden` still locks body scroll when it opens, + * so the shell mounts this only under `lg`. + */ +export function ReasoningDrawer({ + open, + onOpenChange, + triggerClassName, + ...panel +}: ReasoningDrawerProps) { + const [snap, setSnap] = useState(SNAP_POINTS[0] ?? 0.4); + const hasReasoning = (panel.reasoning ?? '').length > 0; + + return ( + + + + + + +