Skip to content

Commit 1986600

Browse files
rsasaki0109claude
andcommitted
Add "beat the robot" challenge mode to the playground (#12)
Add a "Score 10 seeds" button to the pick_and_retry scenario that scores the edited agent against the shipped one across 10 seeds and shows a baseline-vs-you scoreboard (success rate, reward, steps, retries, grasp misses) with a win/lose verdict. Scoring across many seeds is the point: a single-seed score rewards overfitting (dropping the retry schedule solves seed 3 in 2 steps), but robustness across seeds does not — an agent that ignores its belief drops to a 0% success rate. This turns "edit the brain" into a sticky, shareable challenge and teaches why the retry/belief logic exists. - pir/viz/playground_trace.py: score_pick_and_retry(run_agent, agent_factory, seeds) aggregates trace summaries; run_agent is passed in to keep the module decoupled from examples/. - docs/playground.{html,js,css}: challenge panel (pick_and_retry only), driver that scores baseline + edited agent and returns plain JSON, scoreboard with per-metric "better" highlighting and verdict. - tests/test_playground_trace.py: scorer shape, shipped agent is 100% robust, and a belief-ignoring agent scores strictly worse. Verified the exact driver in an unpacked-bundle sim: shipped agent 100% / reward 0.65 vs a belief-ignoring agent 0% / reward -1.2. Full suite green (124 tests). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f54ab39 commit 1986600

7 files changed

Lines changed: 340 additions & 0 deletions

File tree

docs/playground.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,87 @@ button:disabled {
198198
width: 100%;
199199
}
200200

201+
.challenge-panel {
202+
border: 1px solid #d7dcd6;
203+
border-radius: 10px;
204+
margin-top: 12px;
205+
overflow: hidden;
206+
}
207+
208+
.challenge-head {
209+
align-items: center;
210+
background: #eef1ec;
211+
display: flex;
212+
flex-wrap: wrap;
213+
gap: 8px;
214+
justify-content: space-between;
215+
padding: 8px 12px;
216+
}
217+
218+
.challenge-head span {
219+
color: var(--ink-soft, #5a6b67);
220+
font-size: 0.82rem;
221+
}
222+
223+
.challenge-board {
224+
padding: 12px;
225+
}
226+
227+
.challenge-empty {
228+
color: var(--ink-soft, #5a6b67);
229+
font-size: 0.85rem;
230+
}
231+
232+
.challenge-verdict {
233+
border-radius: 8px;
234+
font-weight: 800;
235+
margin-bottom: 10px;
236+
padding: 8px 12px;
237+
}
238+
239+
.challenge-verdict.win {
240+
background: #e3f3dc;
241+
color: #2f6b1f;
242+
}
243+
244+
.challenge-verdict.lose {
245+
background: #f7e6df;
246+
color: #9a3d22;
247+
}
248+
249+
.challenge-verdict.tie {
250+
background: #eef1ec;
251+
color: #4a5b57;
252+
}
253+
254+
.challenge-table {
255+
border-collapse: collapse;
256+
font-size: 0.85rem;
257+
width: 100%;
258+
}
259+
260+
.challenge-table th,
261+
.challenge-table td {
262+
border-bottom: 1px solid #e4e8e2;
263+
padding: 5px 8px;
264+
text-align: right;
265+
}
266+
267+
.challenge-table th:first-child,
268+
.challenge-table td:first-child {
269+
text-align: left;
270+
}
271+
272+
.challenge-table thead th {
273+
color: var(--ink-soft, #5a6b67);
274+
font-weight: 700;
275+
}
276+
277+
.challenge-table td.better {
278+
background: #e3f3dc;
279+
font-weight: 800;
280+
}
281+
201282
.status-strip {
202283
display: grid;
203284
gap: 10px;

docs/playground.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ <h1 id="playground-title">Playground</h1>
124124
</div>
125125
<textarea id="codeCell" class="code-cell" spellcheck="false" rows="20" aria-label="Agent source code"></textarea>
126126
</section>
127+
128+
<section id="challengePanel" class="challenge-panel" aria-label="Challenge" hidden>
129+
<div class="challenge-head">
130+
<span>Challenge — your edited agent vs. the shipped one, across 10 seeds</span>
131+
<button id="scoreButton" type="button">Score 10 seeds</button>
132+
</div>
133+
<div id="challengeBoard" class="challenge-board" aria-live="polite"></div>
134+
</section>
127135
</div>
128136

129137
<aside class="trace-panel" aria-labelledby="trace-title">

docs/playground.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
codeCell: document.getElementById("codeCell"),
5454
runCode: document.getElementById("runCodeButton"),
5555
resetCode: document.getElementById("resetCodeButton"),
56+
challengePanel: document.getElementById("challengePanel"),
57+
challengeBoard: document.getElementById("challengeBoard"),
58+
scoreButton: document.getElementById("scoreButton"),
5659
reset: document.getElementById("resetButton"),
5760
step: document.getElementById("stepButton"),
5861
run: document.getElementById("runButton"),
@@ -105,6 +108,9 @@
105108
elements.resetCode.addEventListener("click", () => {
106109
resetEditedAgent();
107110
});
111+
elements.scoreButton.addEventListener("click", () => {
112+
runChallenge();
113+
});
108114
elements.replay.addEventListener("input", () => {
109115
state.replayIndex = clampReplayIndex(elements.replay.value);
110116
render();
@@ -197,6 +203,11 @@
197203
setRealStatus("running real Python: " + sourcePath(scenario));
198204
if (scenario === "pickretry") {
199205
populateDefaultAgentSource();
206+
if (!elements.challengeBoard.textContent.trim()) {
207+
renderChallengeMessage(
208+
'Edit the agent above, then "Score ' + CHALLENGE_SEEDS + ' seeds" to see if you beat it.'
209+
);
210+
}
200211
}
201212
} catch (error) {
202213
if (scenario === "pickretry") {
@@ -460,6 +471,149 @@
460471
}
461472
}
462473

474+
// --- "Beat the robot" challenge ---------------------------------------------
475+
// Scores the edited agent vs. the shipped one across many seeds. Single-seed
476+
// scoring would reward overfitting; robustness across seeds is the real test.
477+
const CHALLENGE_SEEDS = 10;
478+
const CHALLENGE_DRIVER = [
479+
"import json, os, sys, importlib.util",
480+
"cwd = os.getcwd()",
481+
"if cwd not in sys.path:",
482+
" sys.path.insert(0, cwd)",
483+
"class _NoMatplotlib:",
484+
" def find_spec(self, name, path=None, target=None):",
485+
" if name == 'matplotlib' or name.startswith('matplotlib.'):",
486+
" raise ImportError('matplotlib is intentionally unavailable on the headless browser path')",
487+
" return None",
488+
"sys.meta_path.insert(0, _NoMatplotlib())",
489+
"path = os.path.join(cwd, 'examples', 'manipulation', '01_pick_and_retry.py')",
490+
"spec = importlib.util.spec_from_file_location('pick_and_retry', path)",
491+
"mod = importlib.util.module_from_spec(spec)",
492+
"spec.loader.exec_module(mod)",
493+
"from pir.viz.playground_trace import score_pick_and_retry",
494+
"seeds = list(range(" + CHALLENGE_SEEDS + "))",
495+
"baseline = score_pick_and_retry(mod.run_agent, mod.PickAndRetryAgent, seeds=seeds)",
496+
"src = USER_SRC",
497+
"if src and src.strip():",
498+
" ns = {}",
499+
" exec(src, ns)",
500+
" Agent = ns.get('PickAndRetryAgent')",
501+
" if Agent is None:",
502+
" raise ValueError('Your code must define a class named PickAndRetryAgent')",
503+
" you = score_pick_and_retry(mod.run_agent, Agent, seeds=seeds)",
504+
"else:",
505+
" you = baseline",
506+
"json.dumps({'seeds': seeds, 'baseline': baseline, 'you': you})",
507+
].join("\n");
508+
509+
async function runChallenge() {
510+
stopRun();
511+
elements.scoreButton.disabled = true;
512+
renderChallengeMessage("scoring " + CHALLENGE_SEEDS + " seeds for both agents…");
513+
setRealStatus("running the challenge…");
514+
try {
515+
const pyodide = await ensurePyodide();
516+
pyodide.globals.set("USER_SRC", elements.codeCell.value || "");
517+
const result = JSON.parse(await pyodide.runPythonAsync(CHALLENGE_DRIVER));
518+
renderScoreboard(result);
519+
setRealStatus("challenge scored across " + CHALLENGE_SEEDS + " seeds");
520+
} catch (error) {
521+
renderChallengeMessage("");
522+
setRealStatus("challenge failed: " + error, true);
523+
} finally {
524+
elements.scoreButton.disabled = false;
525+
}
526+
}
527+
528+
function renderChallengeMessage(message) {
529+
elements.challengeBoard.textContent = "";
530+
if (!message) {
531+
return;
532+
}
533+
const note = document.createElement("p");
534+
note.className = "challenge-empty";
535+
note.textContent = message;
536+
elements.challengeBoard.appendChild(note);
537+
}
538+
539+
function challengeVerdict(you, base) {
540+
const same =
541+
you.success_rate === base.success_rate &&
542+
you.mean_reward === base.mean_reward &&
543+
you.mean_steps === base.mean_steps;
544+
if (same) {
545+
return { cls: "tie", text: "You're running the shipped agent — edit it and score again to try to beat it." };
546+
}
547+
// Higher success rate wins; then higher reward; then fewer steps.
548+
let youWin;
549+
if (you.success_rate !== base.success_rate) {
550+
youWin = you.success_rate > base.success_rate;
551+
} else if (you.mean_reward !== base.mean_reward) {
552+
youWin = you.mean_reward > base.mean_reward;
553+
} else {
554+
youWin = you.mean_steps < base.mean_steps;
555+
}
556+
return youWin
557+
? { cls: "win", text: "🏆 You beat the shipped agent across " + CHALLENGE_SEEDS + " seeds!" }
558+
: { cls: "lose", text: "Shipped agent still wins — make your policy more robust across seeds." };
559+
}
560+
561+
function renderScoreboard(result) {
562+
const you = result.you;
563+
const base = result.baseline;
564+
elements.challengeBoard.textContent = "";
565+
566+
const verdict = challengeVerdict(you, base);
567+
const banner = document.createElement("div");
568+
banner.className = "challenge-verdict " + verdict.cls;
569+
banner.textContent = verdict.text;
570+
elements.challengeBoard.appendChild(banner);
571+
572+
// metric label, key, and whether higher is better
573+
const rows = [
574+
["success rate", "success_rate", true, (v) => Math.round(v * 100) + "%"],
575+
["mean reward", "mean_reward", true, (v) => v.toFixed(2)],
576+
["mean steps", "mean_steps", false, (v) => v.toFixed(1)],
577+
["mean retries", "mean_retries", false, (v) => v.toFixed(1)],
578+
["mean grasp_miss", "mean_grasp_miss", false, (v) => v.toFixed(1)],
579+
];
580+
581+
const table = document.createElement("table");
582+
table.className = "challenge-table";
583+
const thead = document.createElement("thead");
584+
const headRow = document.createElement("tr");
585+
["metric", "baseline", "you"].forEach((label) => {
586+
const th = document.createElement("th");
587+
th.textContent = label;
588+
headRow.appendChild(th);
589+
});
590+
thead.appendChild(headRow);
591+
table.appendChild(thead);
592+
593+
const tbody = document.createElement("tbody");
594+
rows.forEach(([label, key, higherBetter, fmt]) => {
595+
const tr = document.createElement("tr");
596+
const name = document.createElement("td");
597+
name.textContent = label;
598+
tr.appendChild(name);
599+
600+
const baseCell = document.createElement("td");
601+
baseCell.textContent = fmt(base[key]);
602+
const youCell = document.createElement("td");
603+
youCell.textContent = fmt(you[key]);
604+
605+
if (base[key] !== you[key]) {
606+
const youBetter = higherBetter ? you[key] > base[key] : you[key] < base[key];
607+
(youBetter ? youCell : baseCell).classList.add("better");
608+
}
609+
tr.appendChild(baseCell);
610+
tr.appendChild(youCell);
611+
tbody.appendChild(tr);
612+
});
613+
table.appendChild(tbody);
614+
elements.challengeBoard.appendChild(table);
615+
}
616+
463617
function stepOnce() {
464618
if (state.index >= state.config.steps.length) {
465619
render();
@@ -955,6 +1109,7 @@
9551109
}
9561110
elements.answer.disabled = state.scenario === "pickretry";
9571111
elements.codePanel.hidden = state.scenario !== "pickretry";
1112+
elements.challengePanel.hidden = state.scenario !== "pickretry";
9581113

9591114
renderReplay(replayIndex);
9601115
renderCompare();

docs/pyodide/pir_bundle.zip

577 Bytes
Binary file not shown.

docs/pyodide_playground_strategy.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ All three phases are built and Python-verified; the remaining work is a single
183183
browser pass over Phases 0–3 and (optionally) deleting the JS `clarifying`
184184
preview once the real path is the trusted default.
185185

186+
**Bonus — "beat the robot" challenge. ✅ built (Python path verified).** A
187+
**Score 10 seeds** button scores the edited agent against the shipped one across
188+
10 seeds and shows a baseline-vs-you scoreboard (success rate, reward, steps,
189+
retries, grasp misses) with a win/lose verdict. Scoring across *many* seeds is
190+
deliberate: a single-seed score rewards overfitting (drop the retry schedule and
191+
seed 3 solves in 2 steps), but robustness across seeds does not — an agent that
192+
ignores its belief drops to a 0% success rate. `score_pick_and_retry` (pinned by
193+
`tests/test_playground_trace.py`) does the aggregation; verified locally the
194+
shipped agent scores 100% / reward 0.65 while a belief-ignoring agent scores 0% /
195+
reward -1.2.
196+
186197
## Risks / watch-list
187198

188199
- **First-load latency.** Pyodide core is a few MB. Lazy-load it only when the

pir/viz/playground_trace.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,46 @@ def pick_and_retry_trace_to_playground(
286286
"initial": initial,
287287
"steps": steps,
288288
}
289+
290+
291+
# --- "Beat the robot" challenge scoring -------------------------------------
292+
#
293+
# An agent is scored across many seeds, not one, so a policy that overfits a
294+
# single lucky seed (e.g. dropping the retry schedule to grab the belief mean
295+
# immediately) is exposed by a low success rate. This is the whole lesson: the
296+
# retry/belief logic exists for robustness, not for one episode.
297+
298+
299+
def score_pick_and_retry(run_agent: Any, agent_factory: Any, *, seeds: Any) -> dict[str, Any]:
300+
"""Run ``agent_factory()`` over ``seeds`` and aggregate the trace summaries.
301+
302+
``run_agent`` is the example's own loop (passed in to keep this module
303+
decoupled from examples/); ``agent_factory`` is called once per seed for a
304+
fresh agent. Returns plain-JSON aggregate stats.
305+
"""
306+
307+
seeds = list(seeds)
308+
successes = 0
309+
steps_total = 0.0
310+
retries_total = 0.0
311+
reward_total = 0.0
312+
miss_total = 0.0
313+
for seed in seeds:
314+
summary = run_agent(agent_factory(), seed=seed, render=False).summary()
315+
if summary.success:
316+
successes += 1
317+
steps_total += summary.steps
318+
retries_total += int(summary.counters.get("retry_count", 0) or 0)
319+
reward_total += summary.total_reward
320+
miss_total += summary.failure_counts.get("grasp_miss", 0)
321+
322+
n = len(seeds) or 1
323+
return {
324+
"episodes": len(seeds),
325+
"successes": successes,
326+
"success_rate": round(successes / n, 4),
327+
"mean_steps": round(steps_total / n, 3),
328+
"mean_retries": round(retries_total / n, 3),
329+
"mean_reward": round(reward_total / n, 3),
330+
"mean_grasp_miss": round(miss_total / n, 3),
331+
}

0 commit comments

Comments
 (0)