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:
@@ -0,0 +1,700 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { useUrlState } from '@/lib/url-state';
|
||||
import type {
|
||||
DemoEpisode,
|
||||
DemoModule,
|
||||
DemoStep,
|
||||
RunRef,
|
||||
StoryBeat,
|
||||
} from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BeatSection } from './BeatSection';
|
||||
import { BlindCompare } from './BlindCompare';
|
||||
import { CodeReceipt } from './CodeReceipt';
|
||||
import { DemoErrorBoundary } from './DemoErrorBoundary';
|
||||
import { EnvAnatomy } from './EnvAnatomy';
|
||||
import { LimitsCallout } from './LimitsCallout';
|
||||
import { MetricMover } from './MetricMover';
|
||||
import { ModelCallPanel } from './ModelCallPanel';
|
||||
import { ProvenanceCard } from './ProvenanceCard';
|
||||
import { ReasoningDrawer } from './ReasoningDrawer';
|
||||
import { ReasoningPanel } from './ReasoningPanel';
|
||||
import { RewardBreakdown } from './RewardBreakdown';
|
||||
import { RewardEditor } from './RewardEditor';
|
||||
import type { RewardArm } from './RewardEditor';
|
||||
import { SlotRegion } from './SlotRegion';
|
||||
import { StatStrip } from './StatStrip';
|
||||
import type { Stat } from './StatStrip';
|
||||
import { StepTimeline } from './StepTimeline';
|
||||
import { RecordedBadge, TracePlayer, useTracePlayback } from './TracePlayer';
|
||||
import { VerifyBadge } from './VerifyBadge';
|
||||
import { clampIndex, formatOrDash, useIsDesktop } from './format';
|
||||
import { scoreReward } from './reward-math';
|
||||
import { mockDemo, mockEpisodes, mockRuns } from './mock';
|
||||
|
||||
const REPO_BLOB = 'https://github.com/karti-ai/PIG-Demo/blob/main/';
|
||||
const MANIFEST_URL = '/traces/manifest.json';
|
||||
|
||||
/**
|
||||
* Every demo module in the repo, as an unresolved import each.
|
||||
*
|
||||
* `import.meta.glob` rather than a generated registry import on purpose: this
|
||||
* file must compile and render before any demo directory exists, and a glob
|
||||
* that matches nothing is an empty object rather than a build error.
|
||||
*/
|
||||
const DEMO_MODULES = import.meta.glob<Record<string, unknown>>('/src/demos/*/index.{ts,tsx}');
|
||||
|
||||
export interface DemoBundle<T = unknown> {
|
||||
demo: DemoModule<T>;
|
||||
runs: RunRef[];
|
||||
episodes: Record<string, DemoEpisode>;
|
||||
}
|
||||
|
||||
type LoadState<T> =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; bundle: DemoBundle<T> }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
function isRunRef(value: unknown): value is RunRef {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const run = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof run['id'] === 'string' &&
|
||||
typeof run['label'] === 'string' &&
|
||||
typeof run['path'] === 'string' &&
|
||||
typeof run['model'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isEpisode(value: unknown): value is DemoEpisode {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const episode = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof episode['runId'] === 'string' &&
|
||||
Array.isArray(episode['turns']) &&
|
||||
typeof episode['rewards'] === 'object' &&
|
||||
episode['rewards'] !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest is data on disk, not a typed import, so it is validated rather
|
||||
* than trusted — and three plausible shapes are accepted because the file is
|
||||
* written by a script in another lane and a keyed map, a nested map and a flat
|
||||
* list are all reasonable things for that script to have produced.
|
||||
*/
|
||||
export function extractRuns(json: unknown, slug: string): RunRef[] {
|
||||
if (typeof json !== 'object' || json === null) return [];
|
||||
const root = json as Record<string, unknown>;
|
||||
const nested = root['demos'];
|
||||
const keyed =
|
||||
(Array.isArray(root[slug]) ? root[slug] : undefined) ??
|
||||
(typeof nested === 'object' && nested !== null
|
||||
? (nested as Record<string, unknown>)[slug]
|
||||
: undefined);
|
||||
if (Array.isArray(keyed)) return keyed.filter(isRunRef);
|
||||
|
||||
const flat = Array.isArray(root['runs']) ? root['runs'] : Array.isArray(json) ? json : null;
|
||||
if (flat) {
|
||||
return flat.filter(isRunRef).filter((run) => {
|
||||
const owner = (run as unknown as Record<string, unknown>)['demo'];
|
||||
return owner === undefined || owner === slug;
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function pickModule(mod: Record<string, unknown>): DemoModule | null {
|
||||
const candidate = mod['default'] ?? mod['demo'];
|
||||
if (typeof candidate !== 'object' || candidate === null) return null;
|
||||
const shape = candidate as Record<string, unknown>;
|
||||
return typeof shape['adapt'] === 'function' && typeof shape['Surface'] === 'function'
|
||||
? (candidate as DemoModule)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function loadBundle(slug: string): Promise<DemoBundle> {
|
||||
if (slug === '__mock') {
|
||||
return { demo: mockDemo as unknown as DemoModule, runs: mockRuns, episodes: mockEpisodes };
|
||||
}
|
||||
|
||||
const entry = Object.entries(DEMO_MODULES).find(([path]) =>
|
||||
path.startsWith(`/src/demos/${slug}/index.`),
|
||||
);
|
||||
if (!entry) throw new Error(`No demo is registered under the slug "${slug}".`);
|
||||
const demo = pickModule(await entry[1]());
|
||||
if (!demo) {
|
||||
throw new Error(`The module for "${slug}" does not export a demo that satisfies the contract.`);
|
||||
}
|
||||
|
||||
const manifest = await fetch(MANIFEST_URL, { cache: 'no-cache' })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.catch(() => null);
|
||||
const runs = extractRuns(manifest, slug);
|
||||
|
||||
// One unreadable trace must not blank the page: fetch them all, keep the
|
||||
// ones that parse, and let the shell report the shortfall.
|
||||
const loaded = await Promise.all(
|
||||
runs.map(async (run) => {
|
||||
try {
|
||||
const response = await fetch(run.path, { cache: 'no-cache' });
|
||||
if (!response.ok) return null;
|
||||
const json: unknown = await response.json();
|
||||
return isEpisode(json) ? ([run.id, json] as const) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const episodes: Record<string, DemoEpisode> = {};
|
||||
for (const item of loaded) {
|
||||
if (item) episodes[item[0]] = item[1];
|
||||
}
|
||||
return { demo, runs: runs.filter((run) => episodes[run.id] !== undefined), episodes };
|
||||
}
|
||||
|
||||
export interface DemoShellProps<T = unknown> {
|
||||
/** Overrides the route param. Useful for previews and tests. */
|
||||
slug?: string;
|
||||
/** Skips loading entirely when the caller already has the bundle. */
|
||||
bundle?: DemoBundle<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route component every demo is rendered through.
|
||||
*
|
||||
* It owns four things and no more: loading, the narrative beats, the URL state,
|
||||
* and the page's single polite live region. Everything visual is delegated to
|
||||
* the surfaces in this directory, and the demo module is never reached into —
|
||||
* the shell is generic over the demo's board type and only ever calls `adapt`
|
||||
* and renders `Surface`.
|
||||
*/
|
||||
export function DemoShell<T = unknown>({ slug: slugProp, bundle }: DemoShellProps<T>) {
|
||||
const params = useParams();
|
||||
const slug = slugProp ?? params['slug'] ?? '';
|
||||
const [state, setState] = useState<LoadState<T>>(
|
||||
bundle ? { status: 'ready', bundle } : { status: 'loading' },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (bundle) {
|
||||
setState({ status: 'ready', bundle });
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setState({ status: 'loading' });
|
||||
loadBundle(slug)
|
||||
.then((loaded) => {
|
||||
if (live) setState({ status: 'ready', bundle: loaded as DemoBundle<T> });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!live) return;
|
||||
setState({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [slug, bundle]);
|
||||
|
||||
if (state.status === 'loading') return <ShellSkeleton />;
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div role="alert" className="card mx-auto my-16 max-w-xl p-6">
|
||||
<h1 className="text-lg font-semibold">That demo is not here</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted">{state.message}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="tap mt-4 inline-flex items-center rounded-lg border border-border px-4 text-sm font-medium hover:bg-surface-2"
|
||||
>
|
||||
Back to the gallery
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DemoErrorBoundary demoTitle={state.bundle.demo.meta.title}>
|
||||
<DemoBody bundle={state.bundle} />
|
||||
</DemoErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
const { demo, runs, episodes } = bundle;
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
// The shell writes `?step=` on every advance, including during playback. It
|
||||
// is `@/lib/url-state`'s job to REPLACE rather than push for these — pushing
|
||||
// would turn a six-step run into six back-button presses.
|
||||
const [runParam, setRunParam] = useUrlState('run', '');
|
||||
const [stepParam, setStepParam] = useUrlState('step', '0');
|
||||
const [tabParam, setTabParam] = useUrlState('tab', '');
|
||||
|
||||
const run = useMemo(
|
||||
() => runs.find((candidate) => candidate.id === runParam) ?? runs[0],
|
||||
[runs, runParam],
|
||||
);
|
||||
const episode = run ? episodes[run.id] : undefined;
|
||||
|
||||
const steps: DemoStep<T>[] = useMemo(
|
||||
() => (episode ? (demo.adapt(episode) as DemoStep<T>[]) : []),
|
||||
[demo, episode],
|
||||
);
|
||||
|
||||
const step = clampIndex(Number(stepParam), steps.length);
|
||||
const setStep = useCallback(
|
||||
(next: number) => setStepParam(String(clampIndex(next, steps.length))),
|
||||
[setStepParam, steps.length],
|
||||
);
|
||||
|
||||
const playback = useTracePlayback({ stepCount: steps.length, step, onStepChange: setStep });
|
||||
const current = steps[step];
|
||||
|
||||
const hasBeat = (surface: StoryBeat['surface']) =>
|
||||
demo.narrative.beats.some((beat) => beat.surface === surface);
|
||||
const timelineInSplit = !hasBeat('scrubber');
|
||||
const extras = demo.tabs ?? [];
|
||||
const extrasInCustom = hasBeat('custom');
|
||||
|
||||
const tabIds = useMemo(() => {
|
||||
const ids = isDesktop ? ['reasoning', 'call'] : ['call'];
|
||||
if (!extrasInCustom) ids.push(...extras.map((tab) => tab.id));
|
||||
return ids;
|
||||
}, [isDesktop, extras, extrasInCustom]);
|
||||
const activeTab = tabIds.includes(tabParam) ? tabParam : (tabIds[0] ?? 'call');
|
||||
|
||||
const arms: RewardArm[] = useMemo(
|
||||
() =>
|
||||
runs.map((candidate) => {
|
||||
const armEpisode = episodes[candidate.id];
|
||||
const arm: RewardArm = {
|
||||
id: candidate.id,
|
||||
label: candidate.label,
|
||||
values: armEpisode?.rewards ?? {},
|
||||
};
|
||||
if (candidate.intervention) arm.note = candidate.intervention;
|
||||
return arm;
|
||||
}),
|
||||
[runs, episodes],
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
arms.map((arm) => ({
|
||||
label: arm.label,
|
||||
total: scoreReward(demo.reward, arm.values).total,
|
||||
})),
|
||||
[arms, demo.reward],
|
||||
);
|
||||
|
||||
const blindPair = useMemo(() => {
|
||||
for (let i = 0; i < runs.length; i += 1) {
|
||||
for (let j = i + 1; j < runs.length; j += 1) {
|
||||
const left = runs[i];
|
||||
const right = runs[j];
|
||||
if (!left || !right || left.seed !== right.seed) continue;
|
||||
const leftEpisode = episodes[left.id];
|
||||
const rightEpisode = episodes[right.id];
|
||||
if (!leftEpisode || !rightEpisode) continue;
|
||||
return { left, right, leftEpisode, rightEpisode };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [runs, episodes]);
|
||||
|
||||
if (!run || !episode || steps.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-canvas px-4 py-16">
|
||||
<h1 className="text-lg font-semibold">{demo.meta.title}</h1>
|
||||
<p className="mt-2 max-w-prose text-sm leading-relaxed text-muted">
|
||||
No recorded run is available for this demo yet. The environment and its grader are in
|
||||
the repository; the traces are produced by the eval command on the demo's provenance
|
||||
card.
|
||||
</p>
|
||||
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} className="mt-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Surface = demo.Surface as unknown as React.ComponentType<{
|
||||
state: T;
|
||||
compact?: boolean;
|
||||
}>;
|
||||
|
||||
const heroStats: Stat[] = [
|
||||
{
|
||||
label: 'Outcome',
|
||||
value: episode.outcome,
|
||||
tone: episode.outcome === 'solved' ? 'positive' : 'warning',
|
||||
hint: episode.truncated ? 'Truncated before a terminal state' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Total reward',
|
||||
value: formatOrDash(scoreReward(demo.reward, episode.rewards).total),
|
||||
tone: 'brand',
|
||||
hint: 'Shipped weights',
|
||||
},
|
||||
{ label: 'Steps', value: steps.length, hint: 'Model calls in this run' },
|
||||
{ label: 'Seed', value: episode.seed, hint: 'Same seed reproduces this board' },
|
||||
];
|
||||
|
||||
const reasoningPanel = (
|
||||
<ReasoningPanel
|
||||
reasoning={current?.reasoning ?? null}
|
||||
durationMs={current?.call?.durationMs ?? null}
|
||||
playing={playback.playing}
|
||||
speed={playback.speed}
|
||||
stepIndex={step}
|
||||
/>
|
||||
);
|
||||
|
||||
const timeline = (
|
||||
<StepTimeline
|
||||
steps={steps}
|
||||
current={step}
|
||||
onSelect={(next) => {
|
||||
playback.setPlaying(false);
|
||||
setStep(next);
|
||||
}}
|
||||
Surface={Surface}
|
||||
onTogglePlay={playback.toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSurface = (beat: StoryBeat) => {
|
||||
switch (beat.surface) {
|
||||
case 'hero':
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
|
||||
<div className="card w-fit p-4">
|
||||
<Surface state={(steps[steps.length - 1] as DemoStep<T>).state} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<StatStrip stats={heroStats} />
|
||||
<RecordedBadge
|
||||
model={run.model}
|
||||
capturedAt={run.capturedAt}
|
||||
{...(run.intervention ? { intervention: run.intervention } : {})}
|
||||
className="ml-0 w-fit"
|
||||
/>
|
||||
<p className="max-w-prose text-sm leading-relaxed text-muted">
|
||||
{demo.narrative.thesis}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'anatomy':
|
||||
return <EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} />;
|
||||
|
||||
case 'split-play':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{runs.length > 1 ? (
|
||||
<RunSwitcher
|
||||
runs={runs}
|
||||
activeId={run.id}
|
||||
onSelect={(id) => {
|
||||
playback.setPlaying(false);
|
||||
setRunParam(id);
|
||||
setStepParam('0');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TracePlayer
|
||||
playing={playback.playing}
|
||||
onPlayingChange={playback.setPlaying}
|
||||
speed={playback.speed}
|
||||
onSpeedChange={playback.setSpeed}
|
||||
onRestart={playback.restart}
|
||||
step={step}
|
||||
stepCount={steps.length}
|
||||
onStepChange={(next) => {
|
||||
playback.setPlaying(false);
|
||||
setStep(next);
|
||||
}}
|
||||
model={run.model}
|
||||
capturedAt={run.capturedAt}
|
||||
{...(run.intervention ? { intervention: run.intervention } : {})}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
|
||||
<div className="card w-fit p-4">
|
||||
{current ? <Surface state={current.state} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-3">
|
||||
{!isDesktop ? (
|
||||
<ReasoningDrawer
|
||||
reasoning={current?.reasoning ?? null}
|
||||
durationMs={current?.call?.durationMs ?? null}
|
||||
playing={playback.playing}
|
||||
speed={playback.speed}
|
||||
stepIndex={step}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Tabs.Root value={activeTab} onValueChange={setTabParam}>
|
||||
<Tabs.List
|
||||
aria-label="Details for this step"
|
||||
className="flex gap-1 overflow-x-auto rounded-lg bg-surface-2 p-1"
|
||||
>
|
||||
{isDesktop ? <TabTrigger value="reasoning">Reasoning</TabTrigger> : null}
|
||||
<TabTrigger value="call">Model call</TabTrigger>
|
||||
{!extrasInCustom
|
||||
? extras.map((tab) => (
|
||||
<TabTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabTrigger>
|
||||
))
|
||||
: null}
|
||||
</Tabs.List>
|
||||
|
||||
{isDesktop ? (
|
||||
<Tabs.Content value="reasoning" className="mt-3 focus-visible:outline-none">
|
||||
{reasoningPanel}
|
||||
</Tabs.Content>
|
||||
) : null}
|
||||
<Tabs.Content value="call" className="mt-3 focus-visible:outline-none">
|
||||
<ModelCallPanel call={current?.call ?? null} />
|
||||
{current?.reply ? (
|
||||
<div className="card mt-3 p-3">
|
||||
<h3 className="text-sm font-semibold">Reply</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-relaxed">
|
||||
{current.reply}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Tabs.Content>
|
||||
{!extrasInCustom
|
||||
? extras.map((tab) => (
|
||||
<Tabs.Content
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="mt-3 focus-visible:outline-none"
|
||||
>
|
||||
<tab.Component />
|
||||
</Tabs.Content>
|
||||
))
|
||||
: null}
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timelineInSplit ? timeline : null}
|
||||
<SlotRegion id="below-board" />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'scrubber':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{timeline}
|
||||
<SlotRegion id="below-timeline" />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'reward-editor':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<RewardBreakdown
|
||||
spec={demo.reward}
|
||||
values={episode.rewards}
|
||||
{...(episode.metrics ? { metrics: episode.metrics } : {})}
|
||||
/>
|
||||
<VerifyBadge demo={demo} episode={episode} />
|
||||
{arms.length > 1 ? <RewardEditor spec={demo.reward} arms={arms} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'metric': {
|
||||
const currentTotal = scoreReward(demo.reward, episode.rewards).total;
|
||||
const first = totals[0];
|
||||
const series = totals
|
||||
.filter((entry): entry is { label: string; total: number } => entry.total !== null)
|
||||
.map((entry) => ({ x: entry.label, y: entry.total }));
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MetricMover
|
||||
label={`Total reward — ${run.label}`}
|
||||
value={currentTotal ?? 0}
|
||||
{...(first && first.total !== null && first.label !== run.label
|
||||
? { baseline: { value: first.total, label: first.label } }
|
||||
: {})}
|
||||
series={series}
|
||||
caption={
|
||||
'Every point is a recorded run scored by the same grader. Nothing here is a projection.'
|
||||
}
|
||||
/>
|
||||
{blindPair ? (
|
||||
<BlindCompare
|
||||
seed={blindPair.left.seed}
|
||||
Surface={Surface}
|
||||
a={{
|
||||
runId: blindPair.left.id,
|
||||
label: blindPair.left.label,
|
||||
model: blindPair.left.model,
|
||||
...(blindPair.left.intervention
|
||||
? { intervention: blindPair.left.intervention }
|
||||
: {}),
|
||||
steps: demo.adapt(blindPair.leftEpisode) as DemoStep<T>[],
|
||||
total: scoreReward(demo.reward, blindPair.leftEpisode.rewards).total,
|
||||
}}
|
||||
b={{
|
||||
runId: blindPair.right.id,
|
||||
label: blindPair.right.label,
|
||||
model: blindPair.right.model,
|
||||
...(blindPair.right.intervention
|
||||
? { intervention: blindPair.right.intervention }
|
||||
: {}),
|
||||
steps: demo.adapt(blindPair.rightEpisode) as DemoStep<T>[],
|
||||
total: scoreReward(demo.reward, blindPair.rightEpisode.rewards).total,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'receipt':
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<ProvenanceCard provenance={demo.provenance} run={run} />
|
||||
<CodeReceipt
|
||||
code={demo.reward.source.code}
|
||||
path={demo.reward.source.path}
|
||||
{...(demo.reward.source.marker ? { marker: demo.reward.source.marker } : {})}
|
||||
href={`${REPO_BLOB}${demo.reward.source.path}`}
|
||||
/>
|
||||
<SlotRegion id="after-receipts" className="lg:col-span-2" />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'limits':
|
||||
return <LimitsCallout limits={demo.narrative.limits} />;
|
||||
|
||||
case 'custom':
|
||||
return extras.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{extras.map((tab) => (
|
||||
<tab.Component key={tab.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SlotRegion id="before-limits" />
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-canvas px-4 pb-16" style={{ paddingBottom: 'var(--safe-bottom)' }}>
|
||||
{/*
|
||||
The page's ONE live region. Every step change lands here and nowhere
|
||||
else: with reduced motion the tile animation is gone, so this sentence
|
||||
is the only thing that tells a screen-reader user what just happened.
|
||||
*/}
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{current?.announce ?? ''}
|
||||
</div>
|
||||
|
||||
<header className="pt-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-accent-fg">
|
||||
{demo.meta.vertical.replace(/-/g, ' ')} · for {demo.meta.persona}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight lg:text-3xl">
|
||||
{demo.meta.title}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-prose text-base text-muted">{demo.meta.tagline}</p>
|
||||
<p className="mt-4 max-w-prose border-l-2 border-brand pl-3 text-sm italic leading-relaxed text-fg">
|
||||
{demo.narrative.anxiety}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{demo.narrative.beats.map((beat, index) => (
|
||||
<BeatSection key={beat.id} beat={beat} number={index + 1}>
|
||||
{renderSurface(beat)}
|
||||
</BeatSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabTrigger({ value, children }: { value: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Tabs.Trigger
|
||||
value={value}
|
||||
className="tap flex-1 whitespace-nowrap rounded-md px-3 text-sm font-medium text-muted transition-colors duration-2 ease-enter data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSwitcher({
|
||||
runs,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: {
|
||||
runs: RunRef[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Recorded run"
|
||||
className="flex flex-wrap gap-1 rounded-lg bg-surface-2 p-1"
|
||||
>
|
||||
{runs.map((run) => {
|
||||
const active = run.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onSelect(run.id)}
|
||||
className={cn(
|
||||
'tap rounded-md px-3 text-sm font-medium transition-colors duration-2 ease-enter',
|
||||
active ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{run.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading state. Deliberately shaped like the page it becomes, and with no
|
||||
* spinner: a spinner on this site would imply a live model call, which is the
|
||||
* one thing the whole page is at pains to say is not happening.
|
||||
*/
|
||||
function ShellSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-canvas px-4 py-10" aria-busy="true">
|
||||
<p className="sr-only">Loading the recorded run.</p>
|
||||
<div className="h-8 w-64 rounded-lg bg-surface-2" />
|
||||
<div className="mt-3 h-4 w-96 max-w-full rounded-lg bg-surface-2" />
|
||||
<div className="mt-10 grid gap-3 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<div key={index} className="h-28 rounded-xl bg-surface-2" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 h-64 rounded-xl bg-surface-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Scale, Target, Weight } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { decompose } from '@/lib/demo-kit/reward';
|
||||
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DASH, formatNumber, formatOrDash } from './format';
|
||||
import { scoreReward } from './reward-math';
|
||||
import { EditedChip } from './StatStrip';
|
||||
|
||||
const ROLE_META: Record<RewardComponent['role'], { label: string; Icon: LucideIcon }> = {
|
||||
@@ -17,9 +18,12 @@ export interface RewardBreakdownProps {
|
||||
values: RewardValues;
|
||||
/** Unweighted diagnostics. Rendered, never summed — the contract is explicit. */
|
||||
metrics?: Record<string, number | null>;
|
||||
/** Overridden weights from the editor. Absent means the shipped weights. */
|
||||
weights?: Record<string, number>;
|
||||
/** Set when `weights` came from the visitor rather than the environment. */
|
||||
/**
|
||||
* Components carrying the weights in force. Pass the output of `reweight()`
|
||||
* when the visitor has edited them; omit for the shipped reward.
|
||||
*/
|
||||
components?: readonly RewardComponent[];
|
||||
/** Set when `components` came from the visitor rather than the environment. */
|
||||
edited?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -36,111 +40,102 @@ export function RewardBreakdown({
|
||||
spec,
|
||||
values,
|
||||
metrics,
|
||||
weights,
|
||||
components,
|
||||
edited = false,
|
||||
className,
|
||||
}: RewardBreakdownProps) {
|
||||
const { rows, total } = scoreReward(spec, values, weights);
|
||||
const counterweights = rows.filter((row) => row.component.role === 'counterweight');
|
||||
const inForce = components ?? spec.components;
|
||||
const { rows, total } = decompose(values, inForce);
|
||||
const counterweights = rows.filter((row) => row.role === 'counterweight');
|
||||
|
||||
return (
|
||||
<div className={cn('card overflow-hidden', className)}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
Reward components, their weights and their contribution to the total score
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Component
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
Score
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
Weight
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const role = ROLE_META[row.component.role];
|
||||
const isCounterweight = row.component.role === 'counterweight';
|
||||
return (
|
||||
<tr
|
||||
key={row.component.key}
|
||||
className={cn(isCounterweight && 'bg-accent-subtle/40')}
|
||||
>
|
||||
<th scope="row" className="max-w-0 px-3 py-2.5 text-left font-normal">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-fg">{row.component.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
isCounterweight
|
||||
? 'bg-brand/15 text-accent-fg'
|
||||
: 'bg-surface-2 text-muted',
|
||||
)}
|
||||
>
|
||||
<role.Icon className="h-3 w-3" aria-hidden="true" />
|
||||
{role.label}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
Reward components, their weights and their contribution to the total score
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Component
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
Score
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
Weight
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const role = ROLE_META[row.role];
|
||||
const isCounterweight = row.role === 'counterweight';
|
||||
return (
|
||||
<tr key={row.key} className={cn(isCounterweight && 'bg-accent-subtle/40')}>
|
||||
<th scope="row" className="max-w-[22rem] px-3 py-2.5 text-left font-normal">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-fg">{row.label}</span>
|
||||
<Badge variant={isCounterweight ? 'default' : 'muted'}>
|
||||
<role.Icon className="size-3" aria-hidden="true" />
|
||||
{role.label}
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-snug text-muted">
|
||||
{row.component.description}
|
||||
</span>
|
||||
<span className="mt-0.5 block font-mono text-[11px] text-muted">
|
||||
{row.component.key}
|
||||
</span>
|
||||
</th>
|
||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
{row.score === null ? (
|
||||
<span className="text-muted" title="The environment did not score this run">
|
||||
not scored
|
||||
<span className="mt-0.5 block text-xs leading-snug text-muted">
|
||||
{row.description}
|
||||
</span>
|
||||
) : (
|
||||
formatNumber(row.score)
|
||||
)}
|
||||
</td>
|
||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
<span className={cn(edited && 'text-accent-fg')}>
|
||||
{formatNumber(row.weight, 2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="nums px-3 py-2.5 text-right align-top font-mono font-semibold">
|
||||
{row.value === null ? DASH : formatNumber(row.value)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-border bg-surface-2">
|
||||
<th scope="row" className="px-3 py-2.5 text-left">
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
Total reward
|
||||
{edited ? <EditedChip /> : null}
|
||||
</span>
|
||||
</th>
|
||||
<td colSpan={2} />
|
||||
<td className="nums px-3 py-2.5 text-right font-mono text-base font-semibold">
|
||||
{total === null ? (
|
||||
<span className="text-muted">not scored</span>
|
||||
) : (
|
||||
formatOrDash(total)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<span className="mt-0.5 block font-mono text-[11px] text-muted">{row.key}</span>
|
||||
</th>
|
||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
{row.score === null ? (
|
||||
<span className="text-muted" title="The environment did not score this run">
|
||||
not scored
|
||||
</span>
|
||||
) : (
|
||||
formatNumber(row.score)
|
||||
)}
|
||||
</td>
|
||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
<span className={cn(edited && 'font-semibold text-accent-fg')}>
|
||||
{formatNumber(row.weight, 2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="nums px-3 py-2.5 text-right align-top font-mono font-semibold">
|
||||
{row.value === null ? DASH : formatNumber(row.value)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-border bg-surface-2">
|
||||
<th scope="row" className="px-3 py-2.5 text-left">
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
Total reward
|
||||
{edited ? <EditedChip /> : null}
|
||||
</span>
|
||||
</th>
|
||||
<td colSpan={2} />
|
||||
<td className="nums px-3 py-2.5 text-right font-mono text-base font-semibold">
|
||||
{total === null ? (
|
||||
<span className="text-muted">not scored</span>
|
||||
) : (
|
||||
formatOrDash(total)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{counterweights.length > 0 ? (
|
||||
<p className="border-t border-border bg-accent-subtle/40 px-3 py-2.5 text-xs leading-relaxed text-fg">
|
||||
<span className="font-semibold">
|
||||
{counterweights.map((row) => row.component.label).join(' and ')}
|
||||
{counterweights.map((row) => row.label).join(' and ')}
|
||||
</span>{' '}
|
||||
{counterweights.length > 1 ? 'are counterweights' : 'is the counterweight'}: without a
|
||||
term pulling the other way, the cheapest way to maximise the objective is a behaviour
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
||||
@@ -79,13 +80,8 @@ export function StatStrip({ stats, className, live = false }: StatStripProps) {
|
||||
*/
|
||||
export function EditedChip({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md bg-accent-subtle px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent-fg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Badge className={cn('px-1.5 py-0 text-[10px] uppercase tracking-wide', className)}>
|
||||
edited
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
+118
-190
@@ -1,100 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Circle, Pause, Play, RotateCcw } from 'lucide-react';
|
||||
import { PLAYBACK_SPEEDS } from '@/lib/demo-kit/player';
|
||||
import type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 };
|
||||
}
|
||||
import { formatDate } from './format';
|
||||
|
||||
export interface TracePlayerProps {
|
||||
playing: boolean;
|
||||
@@ -105,7 +14,15 @@ export interface TracePlayerProps {
|
||||
step: number;
|
||||
stepCount: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/** Straight off the run: never a marketing name for the model. */
|
||||
/** 0..1 through the current step, from the player. Drives the hairline. */
|
||||
progress?: number;
|
||||
/**
|
||||
* False when any step's dwell was invented because the trace carried no
|
||||
* duration. Surfaced, not hidden: the player's whole claim is that the
|
||||
* pacing is the model's, and where it is not, it says so.
|
||||
*/
|
||||
timingIsReal?: boolean;
|
||||
/** 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. */
|
||||
@@ -116,11 +33,11 @@ export interface TracePlayerProps {
|
||||
/**
|
||||
* 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
|
||||
* There is no spinner 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.
|
||||
* UI rather than the copy. Hence the permanent badge — not a disclosure tucked
|
||||
* into a footnote, but a fixture of the transport bar for the whole session.
|
||||
*/
|
||||
export function TracePlayer({
|
||||
playing,
|
||||
@@ -131,105 +48,110 @@ export function TracePlayer({
|
||||
step,
|
||||
stepCount,
|
||||
onStepChange,
|
||||
progress = 0,
|
||||
timingIsReal = true,
|
||||
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>
|
||||
<div className={cn('card overflow-hidden', className)}>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
onClick={() => onStepChange(Math.max(step - 1, 0))}
|
||||
disabled={step <= 0}
|
||||
aria-label="Previous step"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
size="touch"
|
||||
onClick={() => onPlayingChange(!playing)}
|
||||
disabled={!canPlay}
|
||||
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
||||
aria-keyshortcuts="Space"
|
||||
>
|
||||
{playing ? <Pause aria-hidden="true" /> : <Play aria-hidden="true" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
onClick={() => onStepChange(Math.min(step + 1, Math.max(stepCount - 1, 0)))}
|
||||
disabled={step >= stepCount - 1}
|
||||
aria-label="Next step"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
onClick={onRestart}
|
||||
aria-label="Restart from the first step"
|
||||
>
|
||||
<RotateCcw 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 className="nums text-sm text-muted">
|
||||
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
|
||||
{stepCount}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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 } : {})}
|
||||
{...(timingIsReal ? {} : { timingNote: 'pacing approximate' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A hairline, not a scrubber: the step chips below are the scrubber. It
|
||||
exists so the pause between steps reads as time passing rather than as
|
||||
the page having stopped. `transition-[width]` covers the ~15 Hz at which
|
||||
the player pushes progress — without it the bar visibly ratchets.
|
||||
*/}
|
||||
<div className="h-0.5 w-full bg-surface-2" aria-hidden="true">
|
||||
<div
|
||||
className="h-full bg-brand transition-[width] duration-1 ease-enter"
|
||||
style={{
|
||||
width: `${
|
||||
stepCount <= 1 ? 0 : ((step + Math.min(Math.max(progress, 0), 1)) / (stepCount - 1)) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +160,8 @@ export interface RecordedBadgeProps {
|
||||
model: string;
|
||||
capturedAt: string;
|
||||
intervention?: string;
|
||||
/** Rendered when the playback pacing is not the model's own. */
|
||||
timingNote?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -245,6 +169,7 @@ export function RecordedBadge({
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
timingNote,
|
||||
className,
|
||||
}: RecordedBadgeProps) {
|
||||
return (
|
||||
@@ -254,7 +179,7 @@ export function RecordedBadge({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Circle className="h-2 w-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
|
||||
<Circle className="size-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>
|
||||
@@ -265,6 +190,9 @@ export function RecordedBadge({
|
||||
{intervention}
|
||||
</span>
|
||||
) : null}
|
||||
{timingNote ? (
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5">{timingNote}</span>
|
||||
) : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CheckCircle2, ChevronDown, HelpCircle, XCircle } from 'lucide-react';
|
||||
import { verifyEpisode } from '@/lib/demo-kit';
|
||||
import type { DemoEpisode, DemoModule, RewardValues } from '@/lib/demo-kit/types';
|
||||
import { VERIFY_TOLERANCE, verifyEpisode } from '@/lib/demo-kit/verify';
|
||||
import type { AnyDemoModule } from '@/lib/demo-kit/registry';
|
||||
import type { DemoEpisode } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DASH, formatDelta, formatNumber, formatOrDash } from './format';
|
||||
import { rewardDelta, scoreReward } from './reward-math';
|
||||
|
||||
/**
|
||||
* Float tolerance for "the browser agrees with Python".
|
||||
*
|
||||
* 1e-9 would be theatre: the two runtimes accumulate a sum in a different
|
||||
* order, and IEEE-754 does not promise associativity. 1e-6 is well below any
|
||||
* difference a reward change would produce and well above the noise.
|
||||
*/
|
||||
const EPSILON = 1e-6;
|
||||
|
||||
type Verdict =
|
||||
| { kind: 'match'; recomputed: RewardValues; recordedTotal: number | null; recomputedTotal: number | null; maxDelta: number; rows: VerifyRow[] }
|
||||
| { kind: 'mismatch'; recomputed: RewardValues; recordedTotal: number | null; recomputedTotal: number | null; maxDelta: number; rows: VerifyRow[]; guilty: string[] }
|
||||
| { kind: 'unverifiable'; reason: string };
|
||||
|
||||
interface VerifyRow {
|
||||
key: string;
|
||||
label: string;
|
||||
recorded: number | null;
|
||||
recomputed: number | null;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface VerifyBadgeProps<T> {
|
||||
demo: DemoModule<T>;
|
||||
export interface VerifyBadgeProps {
|
||||
demo: AnyDemoModule;
|
||||
episode: DemoEpisode;
|
||||
className?: string;
|
||||
/** Open the receipt on load. The sceptic we are writing for opens it anyway. */
|
||||
/** Open the receipt on load. The sceptic we wrote this for opens it anyway. */
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,113 +18,58 @@ export interface VerifyBadgeProps<T> {
|
||||
* The receipt.
|
||||
*
|
||||
* This object exists for one person: the engineer sitting next to the CEO who
|
||||
* assumes the numbers on a vendor's demo page are hard-coded. It re-runs every
|
||||
* recorded move through the TypeScript engine in the visitor's own browser,
|
||||
* rescores it, and prints the comparison — including the delta, to seven
|
||||
* decimals, because a comparison without a delta is an assertion.
|
||||
* assumes the numbers on a vendor's demo page are hard-coded. It re-runs the
|
||||
* recorded trace through the demo's own TypeScript grader, in the visitor's
|
||||
* browser, and prints the comparison — including the delta to seven decimals,
|
||||
* because a comparison without a delta is an assertion.
|
||||
*
|
||||
* It must therefore be allowed to FAIL loudly. A verifier that silently
|
||||
* degrades to "verified" when it cannot check anything is worse than no
|
||||
* verifier: it teaches the sceptic that the badge is decoration.
|
||||
* It must be allowed to FAIL loudly. A badge that degrades to "verified" when
|
||||
* it could not check anything is worse than no badge: it teaches the sceptic
|
||||
* that the whole thing is decoration. The three states come straight from
|
||||
* `verifyEpisode`, and `unverifiable` is never dressed up as either of the
|
||||
* other two.
|
||||
*/
|
||||
export function VerifyBadge<T>({ demo, episode, className, defaultOpen = false }: VerifyBadgeProps<T>) {
|
||||
export function VerifyBadge({ demo, episode, className, defaultOpen = false }: VerifyBadgeProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const result = useMemo(() => verifyEpisode(demo, episode), [demo, episode]);
|
||||
|
||||
const verdict = useMemo<Verdict>(() => {
|
||||
if (!demo.verify) {
|
||||
return {
|
||||
kind: 'unverifiable',
|
||||
reason:
|
||||
'This demo does not ship a browser-side engine, so the recorded scores cannot be re-derived here. The Python that produced them is in the repository and the eval command is below.',
|
||||
};
|
||||
}
|
||||
|
||||
let recomputed: RewardValues | null;
|
||||
try {
|
||||
recomputed = verifyEpisode(demo, episode);
|
||||
} catch (error) {
|
||||
// A verifier that throws is a bug on our side, not a failed run. Say so
|
||||
// rather than showing a red mismatch that blames the recorded numbers.
|
||||
return {
|
||||
kind: 'unverifiable',
|
||||
reason: `The in-browser verifier threw while re-running this episode: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (recomputed === null) {
|
||||
return {
|
||||
kind: 'unverifiable',
|
||||
reason: episode.truncated
|
||||
? 'This run was truncated before the environment reached a terminal state, so there is nothing complete to re-score. The recorded partial numbers are shown as they were captured.'
|
||||
: 'The environment could not re-derive this episode from the recorded transcript. Nothing here is being asserted as verified.',
|
||||
};
|
||||
}
|
||||
|
||||
const deltas = rewardDelta(episode.rewards, recomputed);
|
||||
const labels = new Map(demo.reward.components.map((c) => [c.key, c.label]));
|
||||
const rows: VerifyRow[] = deltas
|
||||
.map(({ key, delta }) => ({
|
||||
key,
|
||||
label: labels.get(key) ?? key,
|
||||
recorded: episode.rewards[key] ?? null,
|
||||
recomputed: recomputed[key] ?? null,
|
||||
delta,
|
||||
}))
|
||||
.sort((a, b) => b.delta - a.delta || a.key.localeCompare(b.key));
|
||||
|
||||
const recordedTotal = scoreReward(demo.reward, episode.rewards).total;
|
||||
const recomputedTotal = scoreReward(demo.reward, recomputed).total;
|
||||
const totalDelta =
|
||||
recordedTotal === null || recomputedTotal === null
|
||||
? recordedTotal === recomputedTotal
|
||||
? 0
|
||||
: Number.POSITIVE_INFINITY
|
||||
: Math.abs(recordedTotal - recomputedTotal);
|
||||
const maxDelta = rows.reduce((worst, row) => Math.max(worst, row.delta), totalDelta);
|
||||
const guilty = rows.filter((row) => row.delta > EPSILON).map((row) => row.label);
|
||||
|
||||
if (guilty.length === 0 && maxDelta <= EPSILON) {
|
||||
return { kind: 'match', recomputed, recordedTotal, recomputedTotal, maxDelta, rows };
|
||||
}
|
||||
return { kind: 'mismatch', recomputed, recordedTotal, recomputedTotal, maxDelta, rows, guilty };
|
||||
}, [demo, episode]);
|
||||
|
||||
if (verdict.kind === 'unverifiable') {
|
||||
if (result.status === 'unverifiable') {
|
||||
return (
|
||||
<section
|
||||
aria-label="Verification"
|
||||
className={cn('card border-border bg-surface-2 p-3', className)}
|
||||
className={cn('card bg-surface-2 p-3', className)}
|
||||
>
|
||||
<p className="flex items-start gap-2 text-sm">
|
||||
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<HelpCircle className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="font-semibold">Unverifiable in your browser.</span>{' '}
|
||||
<span className="text-muted">{verdict.reason}</span>
|
||||
<span className="text-muted">
|
||||
{result.reason ?? 'This run cannot be re-computed here.'}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
{result.recorded !== null ? (
|
||||
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
||||
Recorded {formatOrDash(result.recorded)} · recomputed {DASH} · Δ {DASH}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const matched = verdict.kind === 'match';
|
||||
const matched = result.status === 'match';
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Verification"
|
||||
className={cn(
|
||||
'card overflow-hidden',
|
||||
matched ? 'border-positive/40' : 'border-danger',
|
||||
className,
|
||||
)}
|
||||
className={cn('card overflow-hidden', matched ? 'border-positive/40' : 'border-danger', className)}
|
||||
>
|
||||
<div className={cn('p-3', matched ? 'bg-positive/10' : 'bg-danger/10')}>
|
||||
<p className="flex items-start gap-2 text-sm leading-relaxed">
|
||||
{matched ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-positive" aria-hidden="true" />
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-positive" aria-hidden="true" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-danger" aria-hidden="true" />
|
||||
<XCircle className="mt-0.5 size-4 shrink-0 text-danger" aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{matched ? (
|
||||
@@ -159,19 +82,21 @@ export function VerifyBadge<T>({ demo, episode, className, defaultOpen = false }
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-danger">
|
||||
Mismatch on {verdict.guilty.join(', ')}
|
||||
{result.culprit
|
||||
? `Mismatch on "${result.culprit.label}"`
|
||||
: 'Mismatch against the recorded score'}
|
||||
</span>{' '}
|
||||
<span className="text-fg">
|
||||
— the browser re-run disagrees with the recorded score. Trust the source, not
|
||||
this page.
|
||||
— the browser re-run disagrees with the published number. Trust the source in
|
||||
the repository, not this page.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
||||
Recomputed {formatOrDash(verdict.recomputedTotal)} · recorded{' '}
|
||||
{formatOrDash(verdict.recordedTotal)} · Δ {formatDelta(verdict.maxDelta)}
|
||||
Recomputed {formatOrDash(result.recomputed)} · recorded {formatOrDash(result.recorded)} ·
|
||||
Δ {result.delta === null ? DASH : formatDelta(result.delta)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -182,59 +107,61 @@ export function VerifyBadge<T>({ demo, episode, className, defaultOpen = false }
|
||||
className="tap flex w-full items-center gap-1.5 border-t border-border px-3 text-left text-xs font-medium text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform duration-2 ease-enter', open && 'rotate-180')}
|
||||
className={cn('size-4 transition-transform duration-2 ease-enter', open && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{open ? 'Hide the component-by-component receipt' : 'Show the component-by-component receipt'}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="overflow-x-auto border-t border-border">
|
||||
<table className="nums w-full border-collapse font-mono text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted">
|
||||
<th scope="col" className="px-3 py-1.5 font-medium">
|
||||
Component
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||
Recorded
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||
Recomputed
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-1.5 text-right font-medium">
|
||||
Δ
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{verdict.rows.map((row) => {
|
||||
const bad = row.delta > EPSILON;
|
||||
return (
|
||||
<tr key={row.key} className={cn(bad && 'bg-danger/10')}>
|
||||
<th scope="row" className="px-3 py-1.5 text-left font-normal">
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{row.recorded === null ? DASH : formatNumber(row.recorded, 6)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{row.recomputed === null ? DASH : formatNumber(row.recomputed, 6)}
|
||||
</td>
|
||||
<td
|
||||
className={cn('px-3 py-1.5 text-right', bad ? 'text-danger' : 'text-muted')}
|
||||
>
|
||||
{Number.isFinite(row.delta) ? formatDelta(row.delta) : 'not comparable'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="border-t border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="nums w-full border-collapse font-mono text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted">
|
||||
<th scope="col" className="px-3 py-1.5 font-medium">
|
||||
Component
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||
Recorded
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||
Recomputed
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-1.5 text-right font-medium">
|
||||
Δ
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{result.components.map((row) => {
|
||||
const bad = row.delta !== null && Math.abs(row.delta) > VERIFY_TOLERANCE;
|
||||
return (
|
||||
<tr key={row.key} className={cn(bad && 'bg-danger/10')}>
|
||||
<th scope="row" className="whitespace-nowrap px-3 py-1.5 text-left font-normal">
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{row.recorded === null ? DASH : formatNumber(row.recorded, 6)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{row.recomputed === null ? DASH : formatNumber(row.recomputed, 6)}
|
||||
</td>
|
||||
<td className={cn('px-3 py-1.5 text-right', bad ? 'text-danger' : 'text-muted')}>
|
||||
{/* An em dash, not 0.0000000: one side was never scored,
|
||||
so there is no difference to report. */}
|
||||
{row.delta === null ? DASH : formatDelta(row.delta)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="px-3 py-2 text-[11px] leading-relaxed text-muted">
|
||||
Tolerance {EPSILON.toExponential()}. The browser engine and the Python environment
|
||||
can sum the same terms in a different order, and IEEE-754 addition is not
|
||||
associative, so the comparison is made within a tolerance rather than demanding
|
||||
Tolerance {VERIFY_TOLERANCE.toExponential()}. The recorded numbers came out of Python
|
||||
and these came out of JavaScript; both are IEEE-754 doubles summing the same terms in
|
||||
a different order, so the comparison is made within a tolerance rather than demanding
|
||||
bit-identical floats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,14 +99,13 @@ export function useMediaQuery(query: string, serverValue = false): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduced motion is not only a CSS concern here. The CSS clamps transitions,
|
||||
* but the trace player and the reasoning stream are JS timers: they have to
|
||||
* resolve to their final state immediately, or a visitor who asked for no
|
||||
* motion gets the animation anyway, just without the easing.
|
||||
* Reduced motion is not only a CSS concern here — the reasoning stream is a JS
|
||||
* timer and has to resolve to its final state immediately. Re-exported from the
|
||||
* player rather than reimplemented: two subscriptions to the same media query
|
||||
* can disagree for a frame, and the frame they disagree on is the one where an
|
||||
* animation starts.
|
||||
*/
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
return useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
}
|
||||
export { usePrefersReducedMotion } from '@/lib/demo-kit/player';
|
||||
|
||||
/** The one breakpoint the shell branches on: the drawer/panel split. */
|
||||
export function useIsDesktop(): boolean {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Resolving `DemoMeta.icon` — a lucide export NAME — to a component.
|
||||
*
|
||||
* The obvious implementations are both wrong, and both were tried:
|
||||
*
|
||||
* `import * as lucide from 'lucide-react'` — kills tree-shaking. Every icon
|
||||
* in the library (~1,500) lands in a chunk to render twelve of them.
|
||||
*
|
||||
* `import('lucide-react/dynamicIconImports')` — correct at runtime, but the
|
||||
* map holds a dynamic import per icon, so Rollup emits ~1,500 chunk files
|
||||
* into `dist/` for a static site that serves twelve.
|
||||
*
|
||||
* So the shell keeps an explicit registry. Adding a demo means adding its icon
|
||||
* here; that is one line, and in exchange the entry chunk stays honest. An
|
||||
* unknown name renders the neutral fallback rather than throwing, because a
|
||||
* typo in a demo's metadata must not take the gallery down.
|
||||
*/
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Blocks,
|
||||
Boxes,
|
||||
Braces,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
HeartPulse,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plug,
|
||||
Radio,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Wallet,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
const REGISTRY: Record<string, LucideIcon> = {
|
||||
Blocks,
|
||||
Boxes,
|
||||
Braces,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
HeartPulse,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plug,
|
||||
Radio,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Wallet,
|
||||
Workflow,
|
||||
Zap,
|
||||
};
|
||||
|
||||
export const FallbackDemoIcon: LucideIcon = Boxes;
|
||||
|
||||
/** Every icon name the shell can render, for `check-demos` to assert against. */
|
||||
export const KNOWN_ICON_NAMES: readonly string[] = Object.keys(REGISTRY);
|
||||
|
||||
export function resolveDemoIcon(name: string | undefined): LucideIcon {
|
||||
if (!name) return FallbackDemoIcon;
|
||||
return REGISTRY[name] ?? FallbackDemoIcon;
|
||||
}
|
||||
|
||||
export interface DemoIconProps {
|
||||
/** A lucide export name from `DemoMeta.icon`, e.g. `Grid3x3`. */
|
||||
name: string | undefined;
|
||||
className?: string;
|
||||
/** Icons here are always decorative — the label beside them carries the name. */
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function DemoIcon({ name, className, strokeWidth = 1.75 }: DemoIconProps) {
|
||||
const Icon = resolveDemoIcon(name);
|
||||
return <Icon className={className} strokeWidth={strokeWidth} aria-hidden="true" />;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||
|
||||
export interface ScoredRow {
|
||||
component: RewardComponent;
|
||||
/** The environment's raw per-component score. `null` means NOT SCORED. */
|
||||
score: number | null;
|
||||
/** The weight in force — shipped, or the visitor's edit. */
|
||||
weight: number;
|
||||
/** `score x weight`, or null when the component was not scored. */
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface ScoredReward {
|
||||
rows: ScoredRow[];
|
||||
/**
|
||||
* The weighted sum over components that were actually scored. `null` when
|
||||
* none of them were: a total of 0 would claim the run scored nothing, which
|
||||
* is a different and much stronger statement than "we could not score it".
|
||||
*/
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
/** The weights the environment ships, as a plain map the editor can copy. */
|
||||
export function shippedWeights(spec: RewardSpec): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const component of spec.components) out[component.key] = component.weight;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function scoreReward(
|
||||
spec: RewardSpec,
|
||||
values: RewardValues,
|
||||
weights?: Record<string, number>,
|
||||
): ScoredReward {
|
||||
let total = 0;
|
||||
let anyScored = false;
|
||||
const rows = spec.components.map((component) => {
|
||||
const raw = values[component.key];
|
||||
const score = raw === undefined ? null : raw;
|
||||
const weight = weights?.[component.key] ?? component.weight;
|
||||
const value = score === null ? null : score * weight;
|
||||
if (value !== null) {
|
||||
total += value;
|
||||
anyScored = true;
|
||||
}
|
||||
return { component, score, weight, value };
|
||||
});
|
||||
return { rows, total: anyScored ? total : null };
|
||||
}
|
||||
|
||||
/** True when two reward maps agree to within float noise on every key. */
|
||||
export function rewardDelta(a: RewardValues, b: RewardValues): { key: string; delta: number }[] {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
const out: { key: string; delta: number }[] = [];
|
||||
for (const key of keys) {
|
||||
const left = a[key];
|
||||
const right = b[key];
|
||||
// Both absent or both explicitly not-scored is agreement, not a zero
|
||||
// delta on a number nobody produced.
|
||||
if ((left === null || left === undefined) && (right === null || right === undefined)) {
|
||||
out.push({ key, delta: 0 });
|
||||
continue;
|
||||
}
|
||||
if (left === null || left === undefined || right === null || right === undefined) {
|
||||
// One side scored and the other did not. That is a real disagreement and
|
||||
// it has no numeric magnitude, so flag it as infinite rather than as 0.
|
||||
out.push({ key, delta: Number.POSITIVE_INFINITY });
|
||||
continue;
|
||||
}
|
||||
out.push({ key, delta: Math.abs(left - right) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user