Skip to content

Commit ff6c0fc

Browse files
rsasaki0109claude
andcommitted
Add Pyodide Phase-0 PoC: run the real pick_and_retry loop in the browser (#7)
Implements Phase 0 from docs/pyodide_playground_strategy.md. - scripts/build_pyodide_bundle.py: zip the pure-Python pir package + the pick_and_retry example (~24 KB) for in-browser loading. - docs/pyodide/poc.html: loads Pyodide, loads numpy, unpacks the bundle, and runs the UNMODIFIED run(seed, render=False), printing Trace.summary() with per-stage timings. Blocks matplotlib so the headless path can never silently pull it. Uses zip + unpackArchive (not micropip) to avoid resolving the declared matplotlib dependency and keep the download tiny. - docs/pyodide/pir_bundle.zip: the committed bundle (also rebuilt on Pages deploy). - tests/test_pyodide_bundle.py: pins the zip contents byte-for-byte to the source tree (drift guard) and asserts the bundled loop runs headless with matplotlib blocked. - pages.yml: rebuild the bundle from source before deploy; trigger Pages on pir/** and the builder too. - pyodide_playground_strategy.md: mark Phase 0 built and record the packaging decision + verification. Python path verified locally by simulating Pyodide's unpack-into-cwd with the exact HTML driver: {"steps":4,"success":true,"failure_counts":{"grasp_miss":2}, "total_reward":0.69}, identical to the --no-render CLI. Browser-side JS glue (loadPyodide/unpackArchive/fetch + first-load time) still needs a real browser. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff9ef24 commit ff6c0fc

6 files changed

Lines changed: 305 additions & 4 deletions

File tree

.github/workflows/pages.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ on:
55
branches: ["main"]
66
paths:
77
- "docs/**"
8+
- "pir/**"
9+
- "scripts/build_pyodide_bundle.py"
810
- ".github/workflows/pages.yml"
911
workflow_dispatch:
1012

@@ -29,6 +31,14 @@ jobs:
2931
- name: Check out repository
3032
uses: actions/checkout@v4
3133

34+
- name: Set up Python
35+
uses: actions/setup-python@v5
36+
with:
37+
python-version: "3.12"
38+
39+
- name: Rebuild Pyodide bundle from source
40+
run: python3 scripts/build_pyodide_bundle.py
41+
3242
- name: Configure GitHub Pages
3343
uses: actions/configure-pages@v5
3444

docs/pyodide/pir_bundle.zip

23.5 KB
Binary file not shown.

docs/pyodide/poc.html

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Pyodide PoC — real pick_and_retry loop in the browser</title>
7+
<style>
8+
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #1b1b1b; }
9+
h1 { font-size: 1.4rem; }
10+
p.lede { color: #444; }
11+
button { font-size: 1rem; padding: 0.5rem 1rem; border: 1px solid #888; border-radius: 6px; background: #f6f6f6; cursor: pointer; }
12+
button:disabled { opacity: 0.5; cursor: default; }
13+
#log { white-space: pre-wrap; background: #0f1419; color: #d6deeb; padding: 1rem; border-radius: 8px; margin-top: 1rem; font-family: ui-monospace, monospace; font-size: 0.85rem; min-height: 6rem; }
14+
.ok { color: #9ece6a; }
15+
.err { color: #f7768e; }
16+
.dim { color: #7a88a0; }
17+
code { background: #eee; padding: 0 0.25rem; border-radius: 3px; }
18+
</style>
19+
</head>
20+
<body>
21+
<h1>Proof of concept: the real Python loop, in your browser</h1>
22+
<p class="lede">
23+
This page loads <a href="https://pyodide.org">Pyodide</a> (CPython + NumPy in
24+
WebAssembly), unpacks the actual <code>pir</code> package, and runs the
25+
<strong>unmodified</strong>
26+
<code>examples/manipulation/01_pick_and_retry.py</code> loop headless — no
27+
install, no server, no matplotlib. It prints the resulting
28+
<code>Trace.summary()</code>. This is Phase&nbsp;0 from
29+
<code>docs/pyodide_playground_strategy.md</code>: confirm the real loop runs in
30+
the browser and measure load time before wiring it into the playground UI.
31+
</p>
32+
33+
<button id="run">Run the real loop</button>
34+
<label style="margin-left:1rem;">seed
35+
<input id="seed" type="number" value="3" style="width:4rem;" />
36+
</label>
37+
38+
<div id="log" class="dim">Click “Run the real loop”. First run downloads Pyodide (~a few MB); later runs are cached.</div>
39+
40+
<script>
41+
const logEl = document.getElementById("log");
42+
const runBtn = document.getElementById("run");
43+
const seedEl = document.getElementById("seed");
44+
let pyodideReadyPromise = null;
45+
46+
function log(msg, cls) {
47+
const span = document.createElement("span");
48+
if (cls) span.className = cls;
49+
span.textContent = msg + "\n";
50+
logEl.appendChild(span);
51+
}
52+
function clearLog() { logEl.textContent = ""; logEl.className = ""; }
53+
const now = () => performance.now();
54+
const ms = (t) => `${(now() - t).toFixed(0)} ms`;
55+
56+
async function ensurePyodide() {
57+
if (pyodideReadyPromise) return pyodideReadyPromise;
58+
pyodideReadyPromise = (async () => {
59+
const t = now();
60+
log("loading Pyodide runtime…", "dim");
61+
// eslint-disable-next-line no-undef
62+
const pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.27.2/full/" });
63+
log(` Pyodide ready (${ms(t)})`, "ok");
64+
65+
const tn = now();
66+
log("loading numpy…", "dim");
67+
await pyodide.loadPackage("numpy");
68+
log(` numpy ready (${ms(tn)})`, "ok");
69+
70+
const tz = now();
71+
log("fetching + unpacking pir_bundle.zip…", "dim");
72+
const buf = await (await fetch("./pir_bundle.zip")).arrayBuffer();
73+
await pyodide.unpackArchive(buf, "zip");
74+
log(` bundle unpacked (${ms(tz)})`, "ok");
75+
76+
return pyodide;
77+
})();
78+
return pyodideReadyPromise;
79+
}
80+
81+
const DRIVER = `
82+
import json, os, sys, time, importlib.util
83+
84+
# unpackArchive extracts relative to the current working directory.
85+
cwd = os.getcwd()
86+
if cwd not in sys.path:
87+
sys.path.insert(0, cwd)
88+
89+
# Prove the browser path is matplotlib-free: block it so an accidental import
90+
# fails loudly instead of silently dragging in a multi-MB package.
91+
class _NoMatplotlib:
92+
def find_spec(self, name, path=None, target=None):
93+
if name == "matplotlib" or name.startswith("matplotlib."):
94+
raise ImportError("matplotlib is intentionally unavailable on the headless browser path")
95+
return None
96+
sys.meta_path.insert(0, _NoMatplotlib())
97+
98+
path = os.path.join(cwd, "examples", "manipulation", "01_pick_and_retry.py")
99+
spec = importlib.util.spec_from_file_location("pick_and_retry", path)
100+
mod = importlib.util.module_from_spec(spec)
101+
spec.loader.exec_module(mod)
102+
103+
t0 = time.perf_counter()
104+
trace = mod.run(seed=SEED, render=False)
105+
elapsed_ms = (time.perf_counter() - t0) * 1000.0
106+
107+
s = trace.summary()
108+
result = {
109+
"steps": s.steps,
110+
"success": bool(s.success),
111+
"failure_counts": dict(s.failure_counts),
112+
"total_reward": round(sum(trace.rewards), 3),
113+
"elapsed_ms": round(elapsed_ms, 1),
114+
}
115+
json.dumps(result)
116+
`;
117+
118+
async function run() {
119+
runBtn.disabled = true;
120+
clearLog();
121+
try {
122+
const pyodide = await ensurePyodide();
123+
const seed = parseInt(seedEl.value, 10) || 0;
124+
const tr = now();
125+
log(`running pick_and_retry.run(seed=${seed}, render=False)…`, "dim");
126+
const code = DRIVER.replace("SEED", String(seed));
127+
const out = JSON.parse(await pyodide.runPythonAsync(code));
128+
log(` loop finished (${ms(tr)})`, "ok");
129+
log("");
130+
log("Trace.summary():", "ok");
131+
log(` steps = ${out.steps}`);
132+
log(` success = ${out.success}`);
133+
log(` failure_counts = ${JSON.stringify(out.failure_counts)}`);
134+
log(` total_reward = ${out.total_reward}`);
135+
log(` (pure loop compute: ${out.elapsed_ms} ms)`, "dim");
136+
log("");
137+
log("This is the same numbers `python3 examples/manipulation/01_pick_and_retry.py --no-render` prints locally.", "dim");
138+
} catch (e) {
139+
log("ERROR: " + e, "err");
140+
console.error(e);
141+
} finally {
142+
runBtn.disabled = false;
143+
}
144+
}
145+
146+
runBtn.addEventListener("click", run);
147+
</script>
148+
<script src="https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js"></script>
149+
</body>
150+
</html>

docs/pyodide_playground_strategy.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,26 @@ Two render families cover all five: **grid** (already drawn today) and
8080

8181
## Phased plan
8282

83-
**Phase 0 — proof of concept (½ day).** A standalone `docs/pyodide_poc.html`
84-
that loads Pyodide, installs numpy, fetches `pir` + `pick_and_retry.py`, runs
85-
`run(seed=0, render=False)`, and `console.log`s `trace.summary()`. Goal: confirm
86-
load time and that the real loop runs unmodified. Decide packaging (1) vs (2).
83+
**Phase 0 — proof of concept. ✅ built (Python path verified; needs a browser
84+
check).** [`docs/pyodide/poc.html`](pyodide/poc.html) loads Pyodide, loads numpy,
85+
unpacks [`docs/pyodide/pir_bundle.zip`](pyodide/) (built by
86+
[`scripts/build_pyodide_bundle.py`](../scripts/build_pyodide_bundle.py) — the
87+
pure-Python `pir` package + `01_pick_and_retry.py`, ~24 KB), runs the
88+
**unmodified** `run(seed, render=False)`, and prints `Trace.summary()` with
89+
timings. It blocks `matplotlib` so the headless path can never silently pull it.
90+
91+
Packaging decision: went with a **zip + `unpackArchive`** (option close to (1)),
92+
not micropip — it avoids resolving the declared matplotlib dependency and keeps
93+
the download tiny. `tests/test_pyodide_bundle.py` pins the zip to the source so
94+
it cannot drift, and `pages.yml` rebuilds it on deploy.
95+
96+
Verified locally (CPython simulating Pyodide's unpack-into-cwd, exact driver from
97+
the HTML): `{"steps": 4, "success": true, "failure_counts": {"grasp_miss": 2},
98+
"total_reward": 0.69}` — identical to `python3 .../01_pick_and_retry.py
99+
--no-render`. **Still to confirm in a real browser:** Pyodide first-load time and
100+
that `loadPyodide`/`unpackArchive`/`fetch` behave as expected. Open
101+
`docs/pyodide/poc.html` via a local server (e.g. `python3 -m http.server` from
102+
`docs/`) or on GitHub Pages and click “Run the real loop”.
87103

88104
**Phase 1 — one real loop on the page (1–2 days).** Add a "Run real Python"
89105
toggle to the existing playground for `clarifying_question` (its renderer already

scripts/build_pyodide_bundle.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Bundle the pure-Python `pir` package + flagship examples into a zip.
2+
3+
The zip is loaded directly in the browser with `pyodide.unpackArchive`, so the
4+
Pyodide playground runs the *real* example code with no build step and without
5+
pulling matplotlib (the headless loop path is numpy-only). Re-run this whenever
6+
`pir/` or a bundled example changes; the output is committed so GitHub Pages can
7+
serve it.
8+
9+
Usage:
10+
python3 scripts/build_pyodide_bundle.py
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import zipfile
16+
from pathlib import Path
17+
18+
ROOT = Path(__file__).resolve().parents[1]
19+
OUT = ROOT / "docs" / "pyodide" / "pir_bundle.zip"
20+
21+
# Examples exposed to the browser PoC. Add to this list as more flagship loops
22+
# get a JS renderer (see docs/pyodide_playground_strategy.md).
23+
BUNDLED_EXAMPLES = [
24+
"examples/manipulation/01_pick_and_retry.py",
25+
]
26+
27+
28+
def iter_pir_sources():
29+
for path in sorted((ROOT / "pir").rglob("*.py")):
30+
if "__pycache__" in path.parts:
31+
continue
32+
yield path
33+
34+
35+
def build() -> Path:
36+
OUT.parent.mkdir(parents=True, exist_ok=True)
37+
files = list(iter_pir_sources()) + [ROOT / rel for rel in BUNDLED_EXAMPLES]
38+
39+
with zipfile.ZipFile(OUT, "w", compression=zipfile.ZIP_DEFLATED) as zf:
40+
for path in files:
41+
arcname = path.relative_to(ROOT).as_posix()
42+
zf.write(path, arcname)
43+
44+
size_kb = OUT.stat().st_size / 1024
45+
print(f"wrote {OUT.relative_to(ROOT)} ({len(files)} files, {size_kb:.1f} KB)")
46+
return OUT
47+
48+
49+
if __name__ == "__main__":
50+
build()

tests/test_pyodide_bundle.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Guard the committed Pyodide bundle against drift from the source tree.
2+
3+
The browser PoC (docs/pyodide/poc.html) runs the real `pir` code by unpacking
4+
docs/pyodide/pir_bundle.zip. If `pir/` or a bundled example changes but the zip
5+
is not rebuilt, the browser would silently run stale code. These tests fail in
6+
that case and tell you to re-run scripts/build_pyodide_bundle.py.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import importlib.util
12+
import zipfile
13+
from pathlib import Path
14+
15+
ROOT = Path(__file__).resolve().parents[1]
16+
BUNDLE = ROOT / "docs" / "pyodide" / "pir_bundle.zip"
17+
18+
19+
def _load_builder():
20+
spec = importlib.util.spec_from_file_location(
21+
"build_pyodide_bundle", ROOT / "scripts" / "build_pyodide_bundle.py"
22+
)
23+
assert spec and spec.loader
24+
module = importlib.util.module_from_spec(spec)
25+
spec.loader.exec_module(module)
26+
return module
27+
28+
29+
def test_bundle_exists() -> None:
30+
assert BUNDLE.exists(), "run: python3 scripts/build_pyodide_bundle.py"
31+
32+
33+
def test_bundle_contents_match_source() -> None:
34+
builder = _load_builder()
35+
expected = {p.relative_to(ROOT).as_posix() for p in builder.iter_pir_sources()}
36+
expected |= set(builder.BUNDLED_EXAMPLES)
37+
38+
with zipfile.ZipFile(BUNDLE) as zf:
39+
archived = {info.filename: zf.read(info.filename) for info in zf.infolist()}
40+
41+
assert set(archived) == expected, (
42+
"bundle file list is stale; re-run python3 scripts/build_pyodide_bundle.py"
43+
)
44+
45+
for arcname, data in archived.items():
46+
on_disk = (ROOT / arcname).read_bytes()
47+
assert data == on_disk, (
48+
f"{arcname} differs from source; re-run python3 scripts/build_pyodide_bundle.py"
49+
)
50+
51+
52+
def test_bundled_example_runs_headless_without_matplotlib() -> None:
53+
"""The browser path imports numpy only; prove the loop never needs matplotlib."""
54+
import sys
55+
56+
class _Block:
57+
def find_spec(self, name, path=None, target=None):
58+
if name == "matplotlib" or name.startswith("matplotlib."):
59+
raise ImportError("blocked for test")
60+
return None
61+
62+
blocker = _Block()
63+
sys.meta_path.insert(0, blocker)
64+
try:
65+
example = ROOT / "examples" / "manipulation" / "01_pick_and_retry.py"
66+
spec = importlib.util.spec_from_file_location("pick_and_retry_pyodide", example)
67+
assert spec and spec.loader
68+
module = importlib.util.module_from_spec(spec)
69+
spec.loader.exec_module(module)
70+
trace = module.run(seed=3, render=False)
71+
summary = trace.summary()
72+
assert summary.success
73+
assert summary.failure_counts.get("grasp_miss", 0) >= 1
74+
finally:
75+
sys.meta_path.remove(blocker)

0 commit comments

Comments
 (0)