Files
PIG-Demo/src/components/demo/BlindCompare.tsx
T
karti-ai eb88138d15 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
2026-08-28 16:09:59 -07:00

182 lines
6.5 KiB
TypeScript

import { useState } from 'react';
import type { ComponentType } from 'react';
import { Eye, Trophy } from 'lucide-react';
import type { DemoStep } from '@/lib/demo-kit/types';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { formatOrDash } from './format';
export interface BlindCompareRun<T> {
runId: string;
/** The identity, revealed only after the visitor commits. */
label: string;
model: string;
/** Required by the contract on an `intervened` run; shown at reveal. */
intervention?: string;
steps: DemoStep<T>[];
total: number | null;
}
export type BlindVote = 'A' | 'B' | 'tie';
export interface BlindCompareProps<T> {
/** Both runs must be the same seed or the comparison is meaningless. */
seed: number;
a: BlindCompareRun<T>;
b: BlindCompareRun<T>;
Surface: ComponentType<{ state: T; compact?: boolean }>;
question?: string;
onVote?: (vote: BlindVote) => void;
className?: string;
}
/**
* Two runs on the same seed, unlabelled, until you commit.
*
* The point is not the vote. The point is that the visitor forms an opinion
* from the behaviour BEFORE they learn which one had the better prompt, the
* bigger model or the training run — because once they know, they cannot
* unknow it, and every "obviously the trained one looks better" is worthless
* after the fact.
*
* There is a "reveal without voting" escape on purpose: a visitor who does not
* want to play should not be held hostage by a modal-shaped page.
*/
export function BlindCompare<T>({
seed,
a,
b,
Surface,
question = 'Which agent would you rather have running this?',
onVote,
className,
}: BlindCompareProps<T>) {
const [vote, setVote] = useState<BlindVote | null>(null);
const [revealed, setRevealed] = useState(false);
const commit = (choice: BlindVote) => {
setVote(choice);
setRevealed(true);
onVote?.(choice);
};
const winner: 'A' | 'B' | 'tie' =
a.total === null || b.total === null
? 'tie'
: a.total > b.total
? 'A'
: b.total > a.total
? 'B'
: 'tie';
const sides: { id: 'A' | 'B'; run: BlindCompareRun<T> }[] = [
{ id: 'A', run: a },
{ id: 'B', run: b },
];
return (
<section aria-label="Blind comparison" className={cn('space-y-3', className)}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="text-sm font-semibold">{question}</h3>
<p className="nums text-xs text-muted">Same puzzle, same seed ({seed}).</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{sides.map(({ id, run }) => {
const last = run.steps[run.steps.length - 1];
const picked = vote === id;
return (
<div
key={id}
className={cn(
'card flex flex-col gap-3 p-3 transition-colors duration-2 ease-enter',
picked && 'border-brand',
revealed && winner === id && 'border-positive',
)}
>
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold">Agent {id}</h4>
<span className="nums text-xs text-muted">{run.steps.length} steps</span>
</div>
<div className="rounded-lg bg-surface-2 p-3">
{last ? (
<Surface state={last.state} />
) : (
<p className="text-sm text-muted">This run recorded no steps.</p>
)}
</div>
{!revealed ? (
<Button size="touch" className="w-full" onClick={() => commit(id)}>
Agent {id} is better
</Button>
) : (
<dl className="space-y-1 text-sm">
<div className="flex items-baseline justify-between gap-2">
<dt className="text-muted">Identity</dt>
<dd className="text-right font-medium">{run.label}</dd>
</div>
<div className="flex items-baseline justify-between gap-2">
<dt className="text-muted">Model</dt>
<dd className="nums text-right font-mono text-xs">{run.model}</dd>
</div>
<div className="flex items-baseline justify-between gap-2">
<dt className="text-muted">Intervention</dt>
<dd className="text-right text-xs">
{run.intervention ?? (
<span className="text-muted">none plain rollout</span>
)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-2 border-t border-border pt-1">
<dt className="text-muted">Reward</dt>
<dd className="nums flex items-center gap-1 text-right font-mono font-semibold">
{revealed && winner === id ? (
<Trophy className="size-3.5 text-positive" aria-hidden="true" />
) : null}
{formatOrDash(run.total)}
</dd>
</div>
</dl>
)}
</div>
);
})}
</div>
{!revealed ? (
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="touch" onClick={() => commit('tie')}>
Too close to call
</Button>
<Button variant="ghost" size="touch" className="text-muted" onClick={() => setRevealed(true)}>
<Eye aria-hidden="true" />
Just show me
</Button>
</div>
) : (
<p role="status" className="card bg-surface-2 p-3 text-sm leading-relaxed">
{vote === null ? (
<>Revealed without a vote. </>
) : vote === winner ? (
<>
<span className="font-semibold text-positive">You picked the higher-scoring run.</span>{' '}
</>
) : (
<>
<span className="font-semibold text-warning">
You picked the lower-scoring run.
</span>{' '}
</>
)}
The environment scored these two with the same grader, on the same seed. The difference
between them is stated above and if it is a prompt change rather than a training run,
it says so, because a prompt change presented as a training result is the oldest trick
in this business.
</p>
)}
</section>
);
}