Frontend: site chrome, demo shell, pages, and the contract gates
Five parallel lanes plus an integration pass. The header, gallery, router and sitemap are all generated from the demo registry, so adding src/demos/<slug>/ puts a demo everywhere with zero edits to shared files — which is the whole reason demo nine cannot break demo one. check-demos enforces the twelve contract rules: 142 checks over one live demo. Two worth naming. The shell may not mention a specific slug, because an 'if (slug === wordle)' in src/components/demo/ is a contract bug wearing a patch. And a spec-status demo must ship a real specification — task, actions, grader, counterweight, eval command — since a coming-soon card reads worse than an honest empty gallery. Bundle budget holds: entry 108.79 kB gzipped against a 160 kB ceiling, the demo chunk 21.15 kB against 90 kB. recharts is 108 kB gzipped and lives behind a lazy import so it never touches the entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import * as Slider from '@radix-ui/react-slider';
|
||||
import { ArrowDown, ArrowUp, Minus, RotateCcw } from 'lucide-react';
|
||||
import {
|
||||
isEdited,
|
||||
pruneOverrides,
|
||||
reweight,
|
||||
weightsAreDegenerate,
|
||||
} from '@/lib/demo-kit/reward';
|
||||
import type { WeightOverrides } from '@/lib/demo-kit/reward';
|
||||
import { rewardTotal } from '@/lib/demo-kit/episode';
|
||||
import type { RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatNumber, formatOrDash } from './format';
|
||||
import { scoreReward, shippedWeights } from './reward-math';
|
||||
import { EditedChip } from './StatStrip';
|
||||
|
||||
/** One recorded arm — a run, or a group of runs already reduced to one score. */
|
||||
@@ -20,6 +28,7 @@ export interface RewardPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Raw weights, before normalisation. Keyed by component. */
|
||||
weights: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -28,19 +37,21 @@ export interface RewardEditorProps {
|
||||
arms: RewardArm[];
|
||||
/** Two is the right number. More and the visitor reads instead of playing. */
|
||||
presets?: RewardPreset[];
|
||||
onWeightsChange?: (weights: Record<string, number>, edited: boolean) => void;
|
||||
onWeightsChange?: (overrides: WeightOverrides, edited: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP = 0.05;
|
||||
|
||||
/**
|
||||
* Presets built from the spec's own labels, for a demo that does not supply
|
||||
* its own. Both are stated as the choice a buyer would actually argue for in a
|
||||
* Presets built from the spec's own labels, for a demo that does not supply its
|
||||
* own. Each is phrased as a position someone would actually argue for in a
|
||||
* meeting, not as "preset A" and "preset B".
|
||||
*/
|
||||
function derivePresets(spec: RewardSpec): RewardPreset[] {
|
||||
const shipped = shippedWeights(spec);
|
||||
const shipped: Record<string, number> = {};
|
||||
for (const component of spec.components) shipped[component.key] = component.weight;
|
||||
|
||||
const counterweights = spec.components.filter((c) => c.role === 'counterweight');
|
||||
const objectives = spec.components.filter((c) => c.role === 'objective');
|
||||
const presets: RewardPreset[] = [
|
||||
@@ -53,23 +64,24 @@ function derivePresets(spec: RewardSpec): RewardPreset[] {
|
||||
];
|
||||
|
||||
const firstObjective = objectives[0];
|
||||
if (counterweights.length > 0 && firstObjective) {
|
||||
const onlyObjective = { ...shipped };
|
||||
for (const component of counterweights) onlyObjective[component.key] = 0;
|
||||
const firstCounterweight = counterweights[0];
|
||||
if (firstCounterweight && firstObjective) {
|
||||
const objectiveOnly = { ...shipped };
|
||||
for (const component of counterweights) objectiveOnly[component.key] = 0;
|
||||
presets.push({
|
||||
id: 'objective-only',
|
||||
label: `${firstObjective.label} at any cost`,
|
||||
description: `Drops ${counterweights
|
||||
.map((c) => c.label.toLowerCase())
|
||||
.join(' and ')} to zero.`,
|
||||
weights: onlyObjective,
|
||||
description: `Drops ${counterweights.map((c) => c.label.toLowerCase()).join(' and ')} to zero.`,
|
||||
weights: objectiveOnly,
|
||||
});
|
||||
|
||||
const doubled = { ...shipped };
|
||||
for (const component of counterweights) doubled[component.key] = component.weight * 2;
|
||||
presets.push({
|
||||
id: 'counterweight-heavy',
|
||||
label: `Double ${counterweights[0]?.label.toLowerCase() ?? 'the counterweight'}`,
|
||||
// Quoted, because a component label is a phrase written for a table cell
|
||||
// ("Found it early") and reads as gibberish spliced into a sentence.
|
||||
label: `Twice as much "${firstCounterweight.label}"`,
|
||||
description: 'What a risk-averse buyer would ask for.',
|
||||
weights: doubled,
|
||||
});
|
||||
@@ -77,22 +89,27 @@ function derivePresets(spec: RewardSpec): RewardPreset[] {
|
||||
return presets;
|
||||
}
|
||||
|
||||
function weightsEqual(a: Record<string, number>, b: Record<string, number>): boolean {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const key of keys) {
|
||||
if (Math.abs((a[key] ?? 0) - (b[key] ?? 0)) > 1e-9) return false;
|
||||
}
|
||||
return true;
|
||||
function sameWeights(spec: RewardSpec, overrides: WeightOverrides, preset: RewardPreset): boolean {
|
||||
return spec.components.every((component) => {
|
||||
const current = overrides[component.key] ?? component.weight;
|
||||
const target = preset.weights[component.key] ?? component.weight;
|
||||
return Math.abs(current - target) < 1e-9;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change what "good" means and watch the ranking move.
|
||||
*
|
||||
* The honesty problem this component has to solve: re-weighting recorded scores
|
||||
* is NOT training. It shows you the ranking a different reward would have
|
||||
* produced over these exact attempts; it cannot show you the different attempts
|
||||
* a model trained on that reward would have made. That distinction is the
|
||||
* permanent caption at the bottom, and it is not collapsible.
|
||||
* is NOT training. It shows the ranking a different reward would have produced
|
||||
* over these exact attempts; it cannot show the different attempts a model
|
||||
* trained on that reward would have made. That distinction is the permanent
|
||||
* caption at the bottom, and it is not collapsible.
|
||||
*
|
||||
* Weights are normalised to sum to 1 before scoring — `reweight` does it — so
|
||||
* dragging one slider up trades weight away from the others instead of lifting
|
||||
* every arm at once. Without that, the totals all rise together and the ranking
|
||||
* appears to move when only the scale did.
|
||||
*/
|
||||
export function RewardEditor({
|
||||
spec,
|
||||
@@ -101,32 +118,33 @@ export function RewardEditor({
|
||||
onWeightsChange,
|
||||
className,
|
||||
}: RewardEditorProps) {
|
||||
const shipped = useMemo(() => shippedWeights(spec), [spec]);
|
||||
const [weights, setWeights] = useState<Record<string, number>>(shipped);
|
||||
const [overrides, setOverrides] = useState<WeightOverrides>({});
|
||||
const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
const values = spec.components.map((c) => c.weight);
|
||||
const max = Math.max(2, ...values.map((v) => Math.ceil(Math.abs(v) * 2)));
|
||||
const min = Math.min(0, ...values.map((v) => Math.floor(v)));
|
||||
return { min, max };
|
||||
}, [spec]);
|
||||
const edited = isEdited(overrides, spec.components);
|
||||
const inForce = useMemo(() => reweight(spec.components, overrides), [spec, overrides]);
|
||||
const shippedNormalised = useMemo(() => reweight(spec.components, {}), [spec]);
|
||||
const degenerate = weightsAreDegenerate(inForce);
|
||||
|
||||
const edited = !weightsEqual(weights, shipped);
|
||||
const max = useMemo(
|
||||
() => Math.max(2, ...spec.components.map((c) => Math.ceil(Math.abs(c.weight) * 2))),
|
||||
[spec],
|
||||
);
|
||||
|
||||
const apply = (next: Record<string, number>) => {
|
||||
setWeights(next);
|
||||
onWeightsChange?.(next, !weightsEqual(next, shipped));
|
||||
const apply = (next: WeightOverrides) => {
|
||||
// Pruned before it goes into state: an override that equals the shipped
|
||||
// weight is not an edit, and leaving it in makes the "edited" chip stick
|
||||
// after the visitor drags a slider back where it started.
|
||||
const pruned = pruneOverrides(next, spec.components);
|
||||
setOverrides(pruned);
|
||||
onWeightsChange?.(pruned, isEdited(pruned, spec.components));
|
||||
};
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
const shippedTotals = new Map(
|
||||
arms.map((arm) => [arm.id, scoreReward(spec, arm.values).total]),
|
||||
);
|
||||
const rows = arms.map((arm) => ({
|
||||
arm,
|
||||
total: scoreReward(spec, arm.values, weights).total,
|
||||
shippedTotal: shippedTotals.get(arm.id) ?? null,
|
||||
total: degenerate ? null : rewardTotal(arm.values, inForce),
|
||||
shippedTotal: rewardTotal(arm.values, shippedNormalised),
|
||||
}));
|
||||
// Nulls sort last: an unscored arm is not a zero-scoring arm.
|
||||
const byTotal = (a: { total: number | null }, b: { total: number | null }) => {
|
||||
@@ -143,10 +161,10 @@ export function RewardEditor({
|
||||
rank: index + 1,
|
||||
shippedRank: shippedOrder.indexOf(row.arm.id) + 1,
|
||||
}));
|
||||
}, [arms, spec, weights]);
|
||||
}, [arms, inForce, shippedNormalised, degenerate]);
|
||||
|
||||
const span = useMemo(() => {
|
||||
const totals = ranked.map((row) => row.total).filter((t): t is number => t !== null);
|
||||
const totals = ranked.map((row) => row.total).filter((total): total is number => total !== null);
|
||||
if (totals.length === 0) return { lo: 0, hi: 1 };
|
||||
const lo = Math.min(0, ...totals);
|
||||
const hi = Math.max(...totals);
|
||||
@@ -156,60 +174,54 @@ export function RewardEditor({
|
||||
const leader = ranked[0];
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]', className)}>
|
||||
<div className={cn('grid grid-cols-1 gap-4 lg:grid-cols-2', className)}>
|
||||
<section aria-label="Reward weights" className="card p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Change what good means</h3>
|
||||
{edited ? <EditedChip /> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => apply(shipped)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="tap ml-auto"
|
||||
onClick={() => apply({})}
|
||||
disabled={!edited}
|
||||
className="tap ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<RotateCcw aria-hidden="true" />
|
||||
Reset to shipped
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{effectivePresets.map((preset) => {
|
||||
const active = weightsEqual(weights, preset.weights);
|
||||
const active = sameWeights(spec, overrides, preset);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => apply({ ...preset.weights })}
|
||||
variant={active ? 'subtle' : 'outline'}
|
||||
size="touch"
|
||||
aria-pressed={active}
|
||||
title={preset.description ?? preset.label}
|
||||
className={cn(
|
||||
'tap rounded-lg border px-3 text-left text-xs font-medium transition-colors duration-2 ease-enter',
|
||||
active
|
||||
? 'border-brand bg-accent-subtle text-accent-fg'
|
||||
: 'border-border hover:bg-surface-2',
|
||||
)}
|
||||
onClick={() => apply({ ...preset.weights })}
|
||||
className="text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{spec.components.map((component) => {
|
||||
const value = weights[component.key] ?? component.weight;
|
||||
const changed = Math.abs(value - component.weight) > 1e-9;
|
||||
{spec.components.map((component, index) => {
|
||||
const raw = overrides[component.key] ?? component.weight;
|
||||
const changed = Math.abs(raw - component.weight) > 1e-9;
|
||||
const share = inForce[index]?.weight ?? 0;
|
||||
return (
|
||||
<div key={component.key}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
{/* A <label htmlFor> would point at Radix's root <span>,
|
||||
which is not a labelable element — the association would
|
||||
silently do nothing. The thumb takes its name from this
|
||||
text via aria-labelledby instead. */}
|
||||
<span
|
||||
id={`weight-label-${component.key}`}
|
||||
className="text-sm font-medium text-fg"
|
||||
>
|
||||
{/* A <label htmlFor> would point at Radix's root <span>, which
|
||||
is not a labelable element — the association would silently
|
||||
do nothing. The thumb takes its name from this text. */}
|
||||
<span id={`weight-label-${component.key}`} className="text-sm font-medium text-fg">
|
||||
{component.label}
|
||||
</span>
|
||||
<span
|
||||
@@ -218,90 +230,94 @@ export function RewardEditor({
|
||||
changed ? 'font-semibold text-accent-fg' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{formatNumber(value, 2)}
|
||||
{formatNumber(raw, 2)}
|
||||
<span className="ml-1.5 text-xs font-normal text-muted">
|
||||
{degenerate ? '—' : `${Math.round(share * 100)}%`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-1.5 text-xs leading-snug text-muted">{component.description}</p>
|
||||
<Slider.Root
|
||||
className="relative flex h-6 w-full touch-none select-none items-center"
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
<p className="mb-1 text-xs leading-snug text-muted">{component.description}</p>
|
||||
<Slider
|
||||
min={0}
|
||||
max={max}
|
||||
step={STEP}
|
||||
value={[value]}
|
||||
value={[raw]}
|
||||
onValueChange={(next) =>
|
||||
apply({ ...weights, [component.key]: next[0] ?? component.weight })
|
||||
apply({ ...overrides, [component.key]: next[0] ?? component.weight })
|
||||
}
|
||||
>
|
||||
<Slider.Track className="relative h-1.5 w-full grow rounded-full bg-surface-2">
|
||||
<Slider.Range className="absolute h-full rounded-full bg-brand" />
|
||||
</Slider.Track>
|
||||
{/* 44px of hit area around a 16px dot: the visible thumb is
|
||||
small enough to read the track under it, and still catches
|
||||
a thumb on a phone. */}
|
||||
<Slider.Thumb
|
||||
aria-labelledby={`weight-label-${component.key}`}
|
||||
className="block h-6 w-6 rounded-full border-4 border-brand bg-surface shadow-sm"
|
||||
/>
|
||||
</Slider.Root>
|
||||
aria-labelledby={`weight-label-${component.key}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted">
|
||||
The percentage is the share of the reward each term carries once the weights are
|
||||
normalised. Raising one lowers the others — that is the trade a reward designer
|
||||
actually makes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-label="Ranking under this reward" className="card flex flex-col p-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Ranking under this reward {edited ? <EditedChip className="ml-1 align-middle" /> : null}
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
Ranking under this reward
|
||||
{edited ? <EditedChip /> : null}
|
||||
</h3>
|
||||
|
||||
<ol className="mt-3 space-y-2">
|
||||
{ranked.map((row) => {
|
||||
const moved = row.rank - row.shippedRank;
|
||||
const width =
|
||||
row.total === null
|
||||
? 0
|
||||
: Math.max(2, ((row.total - span.lo) / (span.hi - span.lo)) * 100);
|
||||
return (
|
||||
<li
|
||||
key={row.arm.id}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
|
||||
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="nums text-sm font-semibold text-muted">{row.rank}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{row.arm.label}
|
||||
</span>
|
||||
<RankMove moved={moved} />
|
||||
<span className="nums font-mono text-sm font-semibold">
|
||||
{formatOrDash(row.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
|
||||
{degenerate ? (
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">
|
||||
Every weight is zero, so there is no reward left to rank by. That is not a score of
|
||||
nought — it is a reward that expresses no preference at all.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-3 space-y-2">
|
||||
{ranked.map((row) => {
|
||||
const moved = row.rank - row.shippedRank;
|
||||
const width =
|
||||
row.total === null
|
||||
? 0
|
||||
: Math.max(2, ((row.total - span.lo) / (span.hi - span.lo)) * 100);
|
||||
return (
|
||||
<li
|
||||
key={row.arm.id}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
|
||||
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="nums text-sm font-semibold text-muted">{row.rank}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{row.arm.label}
|
||||
</span>
|
||||
<RankMove moved={moved} />
|
||||
<span className="nums font-mono text-sm font-semibold">
|
||||
{formatOrDash(row.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
{row.arm.note ? (
|
||||
<p className="mt-1 text-xs text-muted">{row.arm.note}</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
{row.arm.note ? <p className="mt-1 text-xs text-muted">{row.arm.note}</p> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{/* User-initiated, so it is safe to announce here without fighting the
|
||||
shell's step-change region: the two never fire from one action. */}
|
||||
{/* Safe to announce here without fighting the shell's step-change
|
||||
region: a slider drag and a step advance never fire from one action. */}
|
||||
<p role="status" className="sr-only">
|
||||
{leader
|
||||
{leader && leader.total !== null
|
||||
? `Leading under this reward: ${leader.arm.label}, ${formatOrDash(leader.total)}.`
|
||||
: 'No arms to rank.'}
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
<p className="mt-auto pt-3 text-xs leading-relaxed text-muted">
|
||||
@@ -316,24 +332,21 @@ export function RewardEditor({
|
||||
function RankMove({ moved }: { moved: number }) {
|
||||
if (moved === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center text-muted" title="Same rank as shipped">
|
||||
<Minus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">unchanged</span>
|
||||
<span className="inline-flex items-center text-muted" title="Same rank as the shipped reward">
|
||||
<Minus className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">rank unchanged</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const up = moved < 0;
|
||||
const places = Math.abs(moved);
|
||||
return (
|
||||
<span
|
||||
className={cn('nums inline-flex items-center text-xs', up ? 'text-positive' : 'text-danger')}
|
||||
title={`${Math.abs(moved)} place${Math.abs(moved) === 1 ? '' : 's'} ${up ? 'up' : 'down'} from the shipped reward`}
|
||||
title={`${places} place${places === 1 ? '' : 's'} ${up ? 'up' : 'down'} from the shipped reward`}
|
||||
>
|
||||
{up ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{Math.abs(moved)}
|
||||
{up ? <ArrowUp className="size-3.5" aria-hidden="true" /> : <ArrowDown className="size-3.5" aria-hidden="true" />}
|
||||
{places}
|
||||
<span className="sr-only">{up ? ' places up' : ' places down'}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user