Skip to content

Commit 1e10ea0

Browse files
rsasaki0109claude
andauthored
Pyodide Phase 1: run the real clarifying loop in the playground (#8)
Add a "Run real Python" toggle to the playground for the clarifying-question scenario. When enabled, the page lazily boots Pyodide, unpacks the pir bundle, and runs the unmodified examples/embodied_ai/35_clarifying_question.py loop headless (numpy only, matplotlib blocked). The real Trace is serialized into the exact config the existing JS renderer consumes, so the browser draws real Python output instead of the JS reimplementation. The JS preview stays the default first paint — Pyodide loads only on toggle — so the instant experience is preserved. Deleting the JS clarifying dynamics is deliberately deferred until the real path is the verified default (noted in the design memo). - pir/viz/playground_trace.py: pure-Python, JSON-friendly Trace -> playground serializer (no numpy/matplotlib), runnable in CPython and Pyodide alike. - tests/test_playground_trace.py: pins the trace->render JSON contract (every field the renderer reads, plain-JSON, real ask->look->pick outcome). - build_pyodide_bundle.py: bundle 35_clarifying_question.py too; rebuilt zip. - Verified the exact browser driver's Python path in CPython (unpack-into-cwd sim): answer=red yields the real ambiguous_goal->resolved->pick at [32,56]. Browser check still pending (boot Pyodide + redraw from the real trace). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4521018 commit 1e10ea0

8 files changed

Lines changed: 420 additions & 21 deletions

File tree

docs/playground.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,17 @@ button:disabled {
138138
min-height: 1.25rem;
139139
}
140140

141+
.real-status {
142+
color: var(--ink-soft, #5a6b67);
143+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
144+
font-size: 0.82rem;
145+
min-height: 1.1rem;
146+
}
147+
148+
.real-status.real-error {
149+
color: #b5482f;
150+
}
151+
141152
.status-strip {
142153
display: grid;
143154
gap: 10px;

docs/playground.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ <h1 id="playground-title">Playground</h1>
6565
<span>Compare</span>
6666
<input id="compareToggle" type="checkbox">
6767
</label>
68+
<label class="toggle-label">
69+
<span>Run real Python</span>
70+
<input id="realPythonToggle" type="checkbox">
71+
</label>
6872
<div class="button-row">
6973
<button id="resetButton" type="button">Reset</button>
7074
<button id="stepButton" type="button">Step</button>
@@ -74,6 +78,7 @@ <h1 id="playground-title">Playground</h1>
7478
</div>
7579
</div>
7680
<div id="copyStatus" class="copy-status" aria-live="polite"></div>
81+
<div id="realStatus" class="real-status" aria-live="polite"></div>
7782

7883
<div class="status-strip" aria-label="Current simulation state">
7984
<div>
@@ -129,6 +134,7 @@ <h2 id="trace-title">Trace</h2>
129134
</section>
130135
</main>
131136

137+
<script src="https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js"></script>
132138
<script src="playground.js"></script>
133139
</body>
134140
</html>

docs/playground.js

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
answer: document.getElementById("answerSelect"),
4646
failureFilter: document.getElementById("failureFilter"),
4747
compare: document.getElementById("compareToggle"),
48+
realPython: document.getElementById("realPythonToggle"),
49+
realStatus: document.getElementById("realStatus"),
4850
reset: document.getElementById("resetButton"),
4951
step: document.getElementById("stepButton"),
5052
run: document.getElementById("runButton"),
@@ -72,16 +74,13 @@
7274
let copyStatusTimer = null;
7375

7476
elements.scenario.addEventListener("change", () => {
75-
stopRun();
76-
state = buildState();
77-
updateLocation(false);
78-
render();
77+
if (elements.scenario.value !== "clarifying") {
78+
elements.realPython.checked = false;
79+
}
80+
rebuild();
7981
});
8082
elements.answer.addEventListener("change", () => {
81-
stopRun();
82-
state = buildState();
83-
updateLocation(false);
84-
render();
83+
rebuild();
8584
});
8685
elements.failureFilter.addEventListener("change", () => {
8786
updateLocation(false);
@@ -91,15 +90,15 @@
9190
updateLocation(false);
9291
render();
9392
});
93+
elements.realPython.addEventListener("change", () => {
94+
rebuild();
95+
});
9496
elements.replay.addEventListener("input", () => {
9597
state.replayIndex = clampReplayIndex(elements.replay.value);
9698
render();
9799
});
98100
elements.reset.addEventListener("click", () => {
99-
stopRun();
100-
state = buildState();
101-
updateLocation(false);
102-
render();
101+
rebuild();
103102
});
104103
elements.step.addEventListener("click", () => {
105104
stepOnce();
@@ -123,23 +122,127 @@
123122
window.setTimeout(startRun, 180);
124123
}
125124

126-
function buildState() {
125+
function buildState(realConfig) {
127126
const scenario = elements.scenario.value;
128127
const answer = elements.answer.value;
129-
const config =
130-
scenario === "household"
131-
? buildHouseholdScenario(answer)
132-
: buildClarifyingScenario(answer);
128+
let config;
129+
let source;
130+
if (realConfig) {
131+
config = realConfig;
132+
source = "python";
133+
} else {
134+
config =
135+
scenario === "household"
136+
? buildHouseholdScenario(answer)
137+
: buildClarifyingScenario(answer);
138+
source = "js";
139+
}
133140
return {
134141
scenario,
135142
answer,
136143
config,
144+
source,
137145
index: 0,
138146
trace: [],
139147
replayIndex: null,
140148
};
141149
}
142150

151+
// Rebuild the playground state, honoring the "Run real Python" toggle. When the
152+
// toggle is on (clarifying only), we lazily boot Pyodide and run the actual
153+
// examples/embodied_ai/35_clarifying_question.py loop, then draw its real trace.
154+
// Otherwise we use the instant JS preview so first paint never waits on Pyodide.
155+
async function rebuild() {
156+
stopRun();
157+
const useReal =
158+
elements.realPython.checked && elements.scenario.value === "clarifying";
159+
if (useReal) {
160+
setRealStatus("booting Pyodide + running real Python…");
161+
try {
162+
const config = await fetchRealClarifyingConfig(elements.answer.value);
163+
state = buildState(config);
164+
setRealStatus(
165+
"running real Python: examples/embodied_ai/35_clarifying_question.py"
166+
);
167+
} catch (error) {
168+
elements.realPython.checked = false;
169+
state = buildState();
170+
setRealStatus("Pyodide failed (" + error + ") — showing JS preview", true);
171+
}
172+
} else {
173+
state = buildState();
174+
setRealStatus("");
175+
}
176+
updateLocation(false);
177+
render();
178+
}
179+
180+
function setRealStatus(message, isError) {
181+
if (!elements.realStatus) {
182+
return;
183+
}
184+
elements.realStatus.textContent = message || "";
185+
elements.realStatus.classList.toggle("real-error", Boolean(isError));
186+
}
187+
188+
// --- Real Python in the browser (Pyodide), lazy-loaded on first use ---------
189+
let pyodideReadyPromise = null;
190+
const realConfigCache = {};
191+
192+
async function ensurePyodide() {
193+
if (pyodideReadyPromise) {
194+
return pyodideReadyPromise;
195+
}
196+
pyodideReadyPromise = (async () => {
197+
// eslint-disable-next-line no-undef
198+
const pyodide = await loadPyodide({
199+
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.2/full/",
200+
});
201+
await pyodide.loadPackage("numpy");
202+
const buffer = await (await fetch("./pyodide/pir_bundle.zip")).arrayBuffer();
203+
await pyodide.unpackArchive(buffer, "zip");
204+
return pyodide;
205+
})();
206+
return pyodideReadyPromise;
207+
}
208+
209+
// Runs the unmodified example headless and serializes its Trace with the same
210+
// pir.viz.playground_trace helper that tests/test_playground_trace.py pins.
211+
// ANSWER is replaced with a JSON string literal before execution.
212+
const CLARIFYING_DRIVER = [
213+
"import json, os, sys, importlib.util",
214+
"cwd = os.getcwd()",
215+
"if cwd not in sys.path:",
216+
" sys.path.insert(0, cwd)",
217+
"class _NoMatplotlib:",
218+
" def find_spec(self, name, path=None, target=None):",
219+
" if name == 'matplotlib' or name.startswith('matplotlib.'):",
220+
" raise ImportError('matplotlib is intentionally unavailable on the headless browser path')",
221+
" return None",
222+
"sys.meta_path.insert(0, _NoMatplotlib())",
223+
"path = os.path.join(cwd, 'examples', 'embodied_ai', '35_clarifying_question.py')",
224+
"spec = importlib.util.spec_from_file_location('clarifying_question', path)",
225+
"mod = importlib.util.module_from_spec(spec)",
226+
"spec.loader.exec_module(mod)",
227+
"from pir.viz.playground_trace import clarifying_trace_to_playground",
228+
"answer = ANSWER",
229+
"trace = mod.run(command='pick the block', answer=answer, render=False)",
230+
"json.dumps(clarifying_trace_to_playground(trace, command='pick the block', answer=answer))",
231+
].join("\n");
232+
233+
async function fetchRealClarifyingConfig(answer) {
234+
const cacheKey = "clarifying:" + answer;
235+
if (realConfigCache[cacheKey]) {
236+
return realConfigCache[cacheKey];
237+
}
238+
const pyodide = await ensurePyodide();
239+
const code = CLARIFYING_DRIVER.replace("ANSWER", JSON.stringify(answer));
240+
const json = await pyodide.runPythonAsync(code);
241+
const config = JSON.parse(json);
242+
realConfigCache[cacheKey] = config;
243+
return config;
244+
}
245+
143246
function stepOnce() {
144247
if (state.index >= state.config.steps.length) {
145248
render();
@@ -626,6 +729,7 @@
626729
elements.run.disabled = state.index >= state.config.steps.length && !timer;
627730
elements.copyTrace.disabled = state.trace.length === 0;
628731
elements.compare.disabled = state.scenario !== "household";
732+
elements.realPython.disabled = state.scenario !== "clarifying";
629733

630734
renderReplay(replayIndex);
631735
renderCompare();

docs/pyodide/pir_bundle.zip

6.35 KB
Binary file not shown.

docs/pyodide_playground_strategy.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,33 @@ that `loadPyodide`/`unpackArchive`/`fetch` behave as expected. Open
101101
`docs/pyodide/poc.html` via a local server (e.g. `python3 -m http.server` from
102102
`docs/`) or on GitHub Pages and click “Run the real loop”.
103103

104-
**Phase 1 — one real loop on the page (1–2 days).** Add a "Run real Python"
105-
toggle to the existing playground for `clarifying_question` (its renderer already
106-
exists). Python produces the trace; JS draws it; delete the JS dynamics for that
107-
scenario. This is the first honest "real Python in your browser" claim.
104+
**Phase 1 — one real loop on the page. ✅ built (Python path verified; needs a
105+
browser check).** The playground ([`docs/playground.html`](playground.html))
106+
has a **"Run real Python"** toggle for `clarifying_question`. When on, it lazily
107+
boots Pyodide, unpacks the bundle, and runs the **unmodified**
108+
`examples/embodied_ai/35_clarifying_question.py` `run(...)` headless; the real
109+
`Trace` is serialized by [`pir/viz/playground_trace.py`](../pir/viz/playground_trace.py)
110+
into the exact config the existing JS renderer consumes, and the page draws that.
111+
The JS preview stays the default first paint (Pyodide is loaded only on toggle),
112+
so the instant experience is preserved.
113+
114+
The trace→render JSON is now a **pinned contract**:
115+
[`tests/test_playground_trace.py`](../tests/test_playground_trace.py) runs the
116+
real loop, serializes it, and asserts every field the renderer reads is present
117+
and plain-JSON (no numpy leaks) — exactly the drift guard the risk list calls for.
118+
119+
Verified locally (CPython simulating Pyodide's unpack-into-cwd, the exact driver
120+
from `playground.js`): for `answer=red` the serializer returns the real
121+
`ask → look → pick` trace (`ambiguous_goal` then resolved belief, pick at
122+
`[32, 56]`), identical to the CLI loop. **Still to confirm in a real browser:**
123+
toggling "Run real Python" boots Pyodide, runs the loop, and the scene/belief/
124+
timeline redraw from the real trace.
125+
126+
Deliberately **deferred** (not yet done): deleting the JS `buildClarifyingScenario`
127+
dynamics. It is kept as the no-Pyodide instant fallback so first paint never waits
128+
on a multi-MB download. Full deletion waits until the real path is the verified
129+
default — at which point the JS mock can be dropped and the contract test becomes
130+
the single source of truth.
108131

109132
**Phase 2 — tabletop renderer + hero loop (1–2 days).** Add the continuous
110133
tabletop renderer and wire `pick_and_retry`. Now the README hero GIF has a

0 commit comments

Comments
 (0)