Capture harness, fixture verification, CI, and the public README

The site does no live inference. Rollouts are captured once against spark-1 and
replayed at their recorded wall-clock — a public demo with no auth cannot hold
an API key, and a recorded run can be scrubbed, permalinked, blind-compared and
verified in ways a live one cannot. What stops it being a video is that the
browser re-derives every number from the recorded moves.

verify_fixtures.py is the Python half of that: it replays every committed
fixture through the engine and reproduces its own rewards. All 16 land at
delta 0.0. A fixture that cannot be regenerated is a claim with no receipt.

First real measurement, thinking off, 8 seeds: solved 0/8. The model repeats
guesses it has already played, invents words (trape, slith, postt, boomy),
and contradicts its own feedback — consistency 0.09 to 0.17. That is the
published failure taxonomy showing up in our own data on the first run, and it
is why `consistency` is a reward component rather than a footnote.

A capture failure is recorded as a turn with a null reply, never dropped. A
capture that silently discarded failed turns would be reporting a better model
than the one that ran.

CI gates both halves and four things that fail silently in production: the word
lists must rebuild byte-identically, the prerendered routes must carry their own
baked og tags (crawlers do not run JS, so without them every shared link
previews as the homepage), no blob: URL may reach the bundle (the site's CSP has
no worker-src, so it falls back to default-src 'self' and a blob worker is
blocked with no error), and the conformance digest must match across languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:47:31 -07:00
parent 69607fbfe9
commit 408ce4a525
43 changed files with 5279 additions and 136 deletions
+63 -16
View File
@@ -1,24 +1,24 @@
/**
* The demo-kit public barrel.
* The demo-kit barrel.
*
* This is the ONLY module a demo under `src/demos/` is allowed to import from
* the shared shell, and it deliberately exposes a small surface: the contract
* types, the two `define*` wrappers, and the two pure helpers a demo's own
* surface might need to render a score honestly.
* Two audiences, and the split between them matters.
*
* The player, the registry, the verifier and the reward editor's arithmetic are
* NOT here. They are shell machinery — a demo that reaches for `usePlayer` is a
* demo that has started rendering its own chrome, and the whole point of the
* contract is that the shell owns chrome so every demo gets the same one. The
* shell imports those from their own modules:
* A DEMO under `src/demos/` may import from `@/lib/demo-kit` and from nothing
* else in the shell. What it should actually reach for is the first block
* below: the contract types, the two `define*` wrappers, and the two pure
* helpers it needs to render a score without inventing one. That restriction is
* enforced by `scripts/check-demos.mjs`, not by what this file happens to
* export — the shell's own surfaces import through here too, and splitting them
* into a second barrel would only mean the check script had two paths to allow
* instead of one.
*
* import { listDemos, loadDemoModule } from '@/lib/demo-kit/registry';
* import { usePlayer } from '@/lib/demo-kit/player';
* import { loadEpisode, listRuns } from '@/lib/demo-kit/episode';
* import { decompose, reweight } from '@/lib/demo-kit/reward';
* import { verifyEpisode } from '@/lib/demo-kit/verify';
* The SHELL may import anything here, or reach into the individual modules
* (`@/lib/demo-kit/player`, `/registry`, `/episode`, `/reward`, `/verify`) when
* it wants one thing without the rest.
*/
/* -- The contract. Demos live here. --------------------------------------- */
export type {
DemoEpisode,
DemoMeta,
@@ -39,5 +39,52 @@ export type {
export { defineDemo, defineMeta } from './define';
/** `null` is "not scored", never 0.0. Demos render absences with these two. */
/** `null` is "not scored", never 0.0. Every absence goes through these two. */
export { isNotScored, rewardTotal } from './episode';
/* -- Shell machinery. ------------------------------------------------------ */
export {
demos,
getDemo,
getVertical,
hasDemo,
listDemos,
listVerticals,
loadDemoModule,
VERTICAL_LABELS,
VERTICAL_ORDER,
} from './registry';
export type { AnyDemoModule, VerticalGroup } from './registry';
export {
clearEpisodeCache,
listRuns,
loadEpisode,
loadManifest,
MANIFEST_PATH,
scoredCount,
} from './episode';
export type { RunManifest } from './episode';
export {
FALLBACK_STEP_MS,
isPlaybackSpeed,
PLAYBACK_SPEEDS,
usePlayer,
usePrefersReducedMotion,
} from './player';
export type { PlaybackSpeed, Player, PlayerOptions } from './player';
export {
decompose,
isEdited,
pruneOverrides,
reweight,
WEIGHT_EPSILON,
weightsAreDegenerate,
} from './reward';
export type { RewardBreakdown, RewardRow, WeightOverrides } from './reward';
export { recomputeRewards, verifyEpisode, verifySummary, VERIFY_TOLERANCE } from './verify';
export type { ComponentComparison, VerifyResult, VerifyStatus } from './verify';
+8
View File
@@ -179,6 +179,14 @@ const demosBySlug: ReadonlyMap<string, DemoMeta> = (() => {
const orderedDemos: readonly DemoMeta[] = [...demosBySlug.values()].sort(byOrderThenSlug);
/**
* The same list as `listDemos()`, as a frozen constant.
*
* Handy where a module wants the lineup at import time rather than in a render.
* It is the SAME array every caller sees — never sort or splice it in place.
*/
export const demos: readonly DemoMeta[] = orderedDemos;
/** Every demo that survived validation, sorted by `order` then slug. */
export function listDemos(): DemoMeta[] {
return [...orderedDemos];
+23
View File
@@ -137,6 +137,29 @@ export function verifyEpisode(module: AnyDemoModule, episode: DemoEpisode): Veri
return result;
}
/**
* Just the recomputed numbers, with none of the comparison.
*
* `verifyEpisode` is the one to reach for — it does the comparing, and the
* comparing is where the honesty rules live. This exists for a surface that
* wants to render the recomputed values itself and only needs the safe call:
* a grader that is missing, throws, or declines returns `null` rather than
* propagating, so no caller can turn a broken verifier into a zero.
*/
export function recomputeRewards(
module: AnyDemoModule,
episode: DemoEpisode,
): RewardValues | null {
if (typeof module.verify !== 'function') return null;
if (episode.truncated === true) return null;
try {
return module.verify(episode);
} catch (error: unknown) {
console.error('[demo-kit] in-browser grader threw', error);
return null;
}
}
/** One line an exec can read, given a result. Keeps the wording in one place. */
export function verifySummary(result: VerifyResult): string {
switch (result.status) {