Landing picks an environment; each environment is a tabbed page opening on Play
ci / web (push) Successful in 3m10s
ci / python (push) Successful in 2m32s

The environment page was a linear scroll of eight narrative beats. That is an
essay, and it is the wrong shape for somebody who has just chosen an
environment and wants to use it. It is now four tabs — Play, Watch, Reward,
Evidence — opening on Play, with the board above the fold at 390x844 and the
anatomy strip directly beneath it. The landing page leads with the picker
instead of burying it under the thesis.

The contract changed rather than layering tabs over beats. `Narrative.beats` is
gone; `claims: Record<DemoTabId, string>` replaces it, one required sentence per
tab. Writing the claim is how an author discovers whether a tab has anything to
say — a tab whose claim is hard to write is usually a tab with nothing in it.
Doing this now costs one migration; doing it after eleven more environments
costs twelve.

Tabs are derived, never declared: Play iff the demo ships an `interactive` mode,
Watch iff it has recorded runs. A demo that could name its own tabs would mean
environment seven inventing a fifth one and the site ceasing to be one product.

One thing the browser caught that no gate would have. The header stat strip
describes the RECORDED RUN, and on Play it sat above the visitor's own empty
board reading "Outcome: failed" — which parses as your game having already
failed before you touch a key. It now renders only on the tabs whose subject is
that run, which also moved the board 54px up the page.

The picker is honest about the shape of the lineup by construction: one built
environment gets its own block and the demo's real board as its thumbnail,
twelve written specifications render dimmed with a Spec badge, and every count
on the page is derived from the data rather than typed.

206 contract checks pass.

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 18:07:34 -07:00
parent f5f45df224
commit 5bbf913664
23 changed files with 2005 additions and 866 deletions
+137
View File
@@ -0,0 +1,137 @@
---
name: new-environment
description: Build a new RL environment demo for this repo, end to end — specification, Python environment, TypeScript port, captured rollouts, and the demo page. Use when adding an environment to demo.primeintellectgrowth.com, promoting a vertical from spec to live, or when asked to "build environment N" / "add the claims demo" / "make the support one real".
---
# Building an environment
The scarce thing here is not code. It is a **defensible environment**: a task
with a grader that computes rather than opines, and a reward that cannot be
maximised by doing the crude thing. Everything in this repository — the probe
ladder, the conformance digest, the contract validator — exists to stop a
plausible-looking fake from shipping.
Work in this order. Each stage has a gate, and a stage that cannot pass its gate
is telling you the environment is wrong, not that the gate is too strict.
## 0. The specification already exists
`src/content/verticals.ts` carries a reviewed entry for every planned
environment: `task`, `reward`, `counterweight`, `persona`, `anxiety`, and a
`rank` (1 is the one to build next). **Start from that entry.** If you are
building something not in that file, add the entry first and have it reviewed
before writing a line of Python — the spec is the cheapest place to discover
that an environment does not work.
## 1. Write `envs/<pkg>/SPEC.md` before any code
Concrete answers, not restatements of the vertical entry:
- **The episode.** Exactly what one instance is, and what ends it.
- **The data.** Where it comes from. If synthetic, say so on the page — never
imply real customer data. If real, name the licence.
- **The action set.** Legal moves, and what happens to a malformed reply.
(It costs a turn and is recorded. It never crashes the rollout.)
- **The grader.** How the score is computed from the transcript, deterministically.
- **The reward components**, with weights summing to 1.0 and a `counterweight`.
- **The probe ladder**: the policies, and what each must score.
- **The held-out slice.**
⚠️ **If the grader needs an LLM judge, stop.** A judge is the exact thing a
verifiable reward is positioned against, and shipping one here would undercut
every other environment on the site. Either find the computable signal, or ship
the environment as a `spec` with the caveat stated — `legal-playbook-redline`
is in the lineup for precisely this reason and is honest about it.
**Gate:** the spec is reviewed adversarially before implementation. Ask of it:
could a competent sceptic call this grader subjective? Could the reward be
maxed by doing something the buyer would hate?
## 2. The Python environment
`envs/<pkg>/``engine.py` (pure, seeded, never raises on bad input),
`protocol.py` (parse/render; a parse failure is a rejected move, not an
exception), `reward.py` (with `# region: pig-demo/reward` markers around the
part the page quotes), a reference policy, and `tests/`.
Add the policies to `envs/probe.py` and register them for your taskset.
**Gate:** `uv run python envs/probe.py` passes every assertion. Specifically:
doing nothing scores exactly `0.000`; no weighted component is flat across the
ladder; and **your two good policies do not dominate each other** — if one wins
on every component, your counterweight is decorative and the reward editor on
the page would be theatre.
Two mistakes already made here, both worth avoiding twice:
- Score the counterweight over turns **spent**, not moves accepted. Otherwise a
policy that acts once and then jams the parser scores a perfect 1.0 on it.
- The efficiency component's denominator is the depth **your shipped reference
policy** reaches, not a true optimum. Grade against an optimum and your own
oracle rung fails its assertion on some seeds.
## 3. The TypeScript port
`src/demos/<slug>/engine.ts` mirrors the Python. Both sides hash the full
cross-product of the task set and the digest is committed and gated in CI.
⚠️ Never use a language's built-in RNG for seeding. `random.Random(seed)` and
any JS PRNG disagree, so the same seed picks a different instance on each side
and every `?seed=` permalink silently shows a different task than the run it
claims to replay. Use FNV-1a over the decimal seed, as `engine.py` does.
**Gate:** `pnpm conformance` — the two digests match.
## 4. Capture
`uv run python envs/capture.py --arm <arm> --seeds 0-7`
⚠️ **spark-1 serves one model and is single-stream. Capture is a serialised
queue, never parallel.** A thinking-enabled arm can take minutes per seed.
Front-end work does not wait on it — build against `mock.tsx` instead.
Capture at least: a weak arm, a strong arm, and a generated reference policy.
Include at least one run the agent **loses**; a demo where the agent always wins
teaches nothing about the reward. A run that had anything done to it is
`kind: 'intervened'` and must name the `intervention`, so a sampling change can
never be read as a training result.
**Gate:** `uv run python envs/verify_fixtures.py` — every fixture replays through
the engine and reproduces its own recorded rewards.
## 5. The demo module
`pnpm demo:new <slug>` scaffolds it. Fill in `meta.ts`, `narrative.ts`
(`claims` for all four tabs), `surface.tsx`, `adapter.ts`, `reward.ts`, and
`interactive` if the task can be played by a human. Read `src/demos/wordle/` as
the reference implementation.
Where the demo needs something the shell does not offer, that is a contract bug
to fix in `demo-kit` or expose as a `SlotRegion` — never an `if (slug === ...)`
in `src/components/demo/`. `check-demos` fails on it.
**Gate:** `pnpm check && pnpm build`.
## 6. Ship
Add the OG card (`node scripts/og.mjs` — x86 only), rebuild, and
`bash deploy/deploy.sh`. Flip the vertical's status so the picker stops
advertising it as a specification.
## The honesty rules, which are not negotiable
- Rollouts are **recorded, not live**, and the page says so.
- The reward editor **re-scores recorded attempts**; it does not retrain.
- Synthetic data is labelled synthetic, on the page, not in a footnote.
- No customer logos and no named companies as customers, ever.
- Cite someone else's number as theirs, with a link, and never re-quote a figure
across a version change without re-measuring it.
- If the environment is materially easier or harder than the real task it
stands for, say which and by how much.
## Running it as a pipeline
`.claude/workflows/new-environment.js` runs stages 15 as a workflow with
adversarial review between them. Invoke with
`Workflow({ name: 'new-environment', args: { slug, vertical, seeds } })`.
Read this file first regardless — the workflow encodes the sequence, this
encodes the judgement.
+182
View File
@@ -0,0 +1,182 @@
export const meta = {
name: 'new-environment',
description: 'Promote a vertical from published spec to a live environment: specification, Python env, TypeScript port, rollouts, demo page',
whenToUse: 'Adding an environment to demo.primeintellectgrowth.com. Pass { slug, vertical, seeds }.',
phases: [
{ title: 'Specify', detail: 'design the environment, then attack the design' },
{ title: 'Implement', detail: 'Python environment and its probe ladder' },
{ title: 'Port', detail: 'TypeScript engine and the demo module, in parallel' },
{ title: 'Capture', detail: 'real rollouts — serialised, spark-1 is single-stream' },
{ title: 'Verify', detail: 'adversarial review across four lenses' },
],
}
const REPO = '/home/kartios/repos/gitea/PIG-Demo'
const slug = (args && args.slug) || 'support-resolution'
const vertical = (args && args.vertical) || 'customer-support-resolution'
const pkg = slug.replace(/-/g, '_')
const seeds = (args && args.seeds) || '0-7'
const BASE = `You are building environment "${slug}" for the repo at ${REPO}, promoting the vertical "${vertical}" from a published specification to a live, playable demo at demo.primeintellectgrowth.com.
**Read \`.claude/skills/new-environment/SKILL.md\` first, in full.** It carries the judgement; this prompt carries only your stage. Also read \`CONTRACT.md\`, \`AGENTS.md\`, and the reference implementation in \`envs/wordle_five/\` and \`src/demos/wordle/\`.
The specification for this vertical — task, reward, counterweight, persona, anxiety — is already written and reviewed in \`src/content/verticals.ts\`. Start from that entry; do not invent a different task.
Package: \`envs/${pkg}/\`. Demo: \`src/demos/${slug}/\`. Traces: \`public/traces/${slug}/\`.
Do NOT run git. Do NOT edit another stage's files.`
const SPEC_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['episode', 'data', 'actions', 'grader', 'components', 'ladder', 'heldOut', 'risks'],
properties: {
episode: { type: 'string' },
data: { type: 'string', description: 'Source, synthetic-or-real, licence' },
actions: { type: 'string' },
grader: { type: 'string', description: 'How the score is computed, deterministically' },
components: { type: 'array', items: { type: 'object', additionalProperties: false,
required: ['key','label','weight','role','definition'],
properties: { key:{type:'string'}, label:{type:'string'}, weight:{type:'number'},
role:{type:'string', enum:['objective','counterweight','gate']}, definition:{type:'string'} } } },
ladder: { type: 'array', items: { type: 'object', additionalProperties: false,
required: ['policy','behaviour','expected'],
properties: { policy:{type:'string'}, behaviour:{type:'string'}, expected:{type:'string'} } } },
heldOut: { type: 'string' },
risks: { type: 'array', items: { type: 'string' } },
},
}
phase('Specify')
const spec = await agent(`${BASE}
## YOUR STAGE: the specification. No code.
Design the environment concretely and write \`envs/${pkg}/SPEC.md\`. Answer the seven questions in the skill's stage 1 with specifics, not restatements of the vertical entry.
The two decisions that make or break this:
**The grader must compute, not opine.** If you find yourself reaching for an LLM judge, you have not found the environment yet. Look for the signal that already exists in the business: a downstream system's response, a settled number, a later observed outcome. If there genuinely is not one, say so plainly in \`risks\` — shipping it as a spec with the caveat stated is a legitimate outcome and better than faking it.
**The counterweight must be in genuine tension with the objective.** Name the crude policy that maxes the objective, and the component that stops it. Then name a good policy that pays the counterweight's price — if you cannot, the counterweight is a gate wearing the wrong label.
Also decide the data honestly. Synthetic is fine and often the only option; it must be labelled synthetic on the page, and it must not imply real customer records.
Return the structured spec AND write SPEC.md.`, { label: 'spec', phase: 'Specify', schema: SPEC_SCHEMA, effort: 'high' })
const specText = spec ? JSON.stringify(spec, null, 2) : '(the spec stage returned nothing)'
const LENSES = [
'DETERMINISM: is this grader really computable from the transcript, every time, with no human or model judgement anywhere in it? Hunt for a step that quietly needs an opinion.',
'GAMEABILITY: find the policy that maximises this reward while doing something the buyer would be appalled by. If you find one, the counterweight is insufficient. Try at least four distinct cheats.',
'HONESTY: would an executive be misled by this? Check the data provenance, whether the task is materially easier than the real job, and whether anything here implies a customer, a result, or a capability we have not got.',
]
const REVIEW_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['verdict', 'findings'],
properties: {
verdict: { type: 'string', enum: ['sound', 'fixable', 'fatal'] },
findings: { type: 'array', items: { type: 'object', additionalProperties: false,
required: ['problem','fix'], properties: { problem:{type:'string'}, fix:{type:'string'} } } },
},
}
const reviews = (await parallel(LENSES.map((lens, i) => () =>
agent(`Attack this environment specification through ONE lens. Be adversarial: your job is to find the reason it should not ship, not to approve it.
LENS: ${lens}
${BASE}
=== THE SPECIFICATION ===
${specText}`, { label: `attack:${i}`, phase: 'Specify', schema: REVIEW_SCHEMA, effort: 'high' })
))).filter(Boolean)
const reviewText = reviews.map((r, i) => `--- LENS ${i} — verdict: ${r.verdict}\n${r.findings.map(f => ` ! ${f.problem}\n -> ${f.fix}`).join('\n')}`).join('\n\n')
if (reviews.some(r => r.verdict === 'fatal')) {
log('A reviewer called the specification fatal. Stopping before implementation.')
return { stopped: 'fatal specification', spec, reviews }
}
phase('Implement')
const python = await agent(`${BASE}
## YOUR STAGE: the Python environment.
Build \`envs/${pkg}/\` — engine, protocol, reward (with \`# region: pig-demo/reward\` markers), a reference policy, and tests. Register your probe policies in \`envs/probe.py\` for taskset "${slug}".
Implement the specification below, INCLUDING every fix the reviewers demanded. Where a reviewer found a gameable policy, add it to the probe ladder as a rung that must score below the good policies — a cheat you have named and measured is a cheat that cannot come back.
**Your gate:** \`uv run python envs/probe.py\` passes every assertion, including that your two good policies do not dominate each other. Iterate until it does. If it cannot pass, the reward is wrong — fix the reward, not the assertion.
=== SPECIFICATION ===
${specText}
=== WHAT THE REVIEWERS DEMANDED ===
${reviewText}`, { label: 'python', phase: 'Implement', effort: 'high' })
phase('Port')
const ported = (await parallel([
() => agent(`${BASE}
## YOUR STAGE: the TypeScript engine.
Port \`envs/${pkg}/\` 's scorer to \`src/demos/${slug}/engine.ts\`, and extend \`scripts/conformance.mjs\` so this environment's digest is gated the way wordle's is. Seed with FNV-1a over the decimal seed — never a built-in RNG, or the same seed picks a different task on each side and every permalink lies.
**Your gate:** \`pnpm conformance\` — the two digests match.
The Python that landed:
${python}`, { label: 'port:ts', phase: 'Port', effort: 'high' }),
() => agent(`${BASE}
## YOUR STAGE: the demo module.
Build \`src/demos/${slug}/\`\`meta.ts\`, \`narrative.ts\` (\`claims\` for all four tabs: play, watch, reward, evidence), \`surface.tsx\`, \`adapter.ts\`, \`reward.ts\`, and \`interactive\` if a human can play this task. Read \`src/demos/wordle/\` as the reference.
Build against \`src/components/demo/mock.tsx\` — do NOT wait for real rollouts, that is the whole point of the wire format.
Anything the demo needs that the shell does not offer is a contract bug to fix in \`demo-kit\` or expose as a \`SlotRegion\`, never a slug check in \`src/components/demo/\`.
**Your gate:** \`pnpm check\` and \`./node_modules/.bin/tsc --noEmit\` clean for your files.
The specification:
${specText}`, { label: 'port:demo', phase: 'Port', effort: 'high' }),
])).filter(Boolean)
phase('Capture')
const captured = await agent(`${BASE}
## YOUR STAGE: real rollouts.
Extend \`envs/capture.py\` for taskset "${slug}" and capture seeds ${seeds} for at least three arms: a weak one, a strong one, and a generated reference policy.
⚠️ **spark-1 serves one model and is single-stream — run the arms one after another, never concurrently.** Check it is up first: \`curl http://100.127.247.67:8001/v1/models\`. A thinking-enabled arm can take minutes per seed; run it in the background, redirect output to a FILE (piping to head/tail block-buffers Python's stdout and a working run looks hung), and poll the written fixtures rather than the log.
Include at least one run the agent loses. Then \`uv run python envs/build_manifest.py\`.
**Your gate:** \`uv run python envs/verify_fixtures.py\` — every fixture replays and reproduces its own recorded rewards at delta 0.
If spark-1 is unreachable, do NOT invent fixtures. Say so and stop; a fabricated rollout on this site would be worse than a missing one.`, { label: 'capture', phase: 'Capture', effort: 'high' })
phase('Verify')
const CHECKS = [
'Run the full gate chain and make it green: uv run python envs/probe.py, uv run pytest, uv run python envs/verify_fixtures.py, pnpm check, pnpm conformance, pnpm build. Fix whatever is broken.',
'Open the new demo in a real browser at 1280px and 390px (playwright is installed; serve dist). Confirm: it appears in the landing picker, all four tabs render, Play works if it has an interactive mode, ?tab= round-trips, zero console errors, no horizontal overflow at 390px.',
'Audit honesty against the rules in the skill: is synthetic data labelled synthetic ON THE PAGE, are recorded runs labelled recorded, does any claim imply a customer or a result we do not have, and does the page state how this environment differs from the real job? Fix the copy where it does not hold.',
]
const verified = (await parallel(CHECKS.map((c, i) => () =>
agent(`${BASE}\n\n## YOUR STAGE: verification.\n\n${c}\n\nReport precisely what you ran, what failed, and what you changed. Do not report success you did not observe.`,
{ label: `verify:${i}`, phase: 'Verify', effort: 'high' })
))).filter(Boolean)
return {
slug, vertical,
spec, reviews: reviewText,
python, ported, captured,
verified,
}