+
+
+Pyodide PoC — real pick_and_retry loop in the browser
+
+
+
+
Proof of concept: the real Python loop, in your browser
+
+ This page loads Pyodide (CPython + NumPy in
+ WebAssembly), unpacks the actual pir package, and runs the
+ unmodified
+ examples/manipulation/01_pick_and_retry.py loop headless — no
+ install, no server, no matplotlib. It prints the resulting
+ Trace.summary(). This is Phase 0 from
+ docs/pyodide_playground_strategy.md: confirm the real loop runs in
+ the browser and measure load time before wiring it into the playground UI.
+
+
+
+
+
+
Click “Run the real loop”. First run downloads Pyodide (~a few MB); later runs are cached.
+
+
+
+
+
diff --git a/docs/pyodide_playground_strategy.md b/docs/pyodide_playground_strategy.md
index 6cda93e..600ac48 100644
--- a/docs/pyodide_playground_strategy.md
+++ b/docs/pyodide_playground_strategy.md
@@ -80,10 +80,26 @@ Two render families cover all five: **grid** (already drawn today) and
## Phased plan
-**Phase 0 — proof of concept (½ day).** A standalone `docs/pyodide_poc.html`
-that loads Pyodide, installs numpy, fetches `pir` + `pick_and_retry.py`, runs
-`run(seed=0, render=False)`, and `console.log`s `trace.summary()`. Goal: confirm
-load time and that the real loop runs unmodified. Decide packaging (1) vs (2).
+**Phase 0 — proof of concept. ✅ built (Python path verified; needs a browser
+check).** [`docs/pyodide/poc.html`](pyodide/poc.html) loads Pyodide, loads numpy,
+unpacks [`docs/pyodide/pir_bundle.zip`](pyodide/) (built by
+[`scripts/build_pyodide_bundle.py`](../scripts/build_pyodide_bundle.py) — the
+pure-Python `pir` package + `01_pick_and_retry.py`, ~24 KB), runs the
+**unmodified** `run(seed, render=False)`, and prints `Trace.summary()` with
+timings. It blocks `matplotlib` so the headless path can never silently pull it.
+
+Packaging decision: went with a **zip + `unpackArchive`** (option close to (1)),
+not micropip — it avoids resolving the declared matplotlib dependency and keeps
+the download tiny. `tests/test_pyodide_bundle.py` pins the zip to the source so
+it cannot drift, and `pages.yml` rebuilds it on deploy.
+
+Verified locally (CPython simulating Pyodide's unpack-into-cwd, exact driver from
+the HTML): `{"steps": 4, "success": true, "failure_counts": {"grasp_miss": 2},
+"total_reward": 0.69}` — identical to `python3 .../01_pick_and_retry.py
+--no-render`. **Still to confirm in a real browser:** Pyodide first-load time and
+that `loadPyodide`/`unpackArchive`/`fetch` behave as expected. Open
+`docs/pyodide/poc.html` via a local server (e.g. `python3 -m http.server` from
+`docs/`) or on GitHub Pages and click “Run the real loop”.
**Phase 1 — one real loop on the page (1–2 days).** Add a "Run real Python"
toggle to the existing playground for `clarifying_question` (its renderer already
diff --git a/scripts/build_pyodide_bundle.py b/scripts/build_pyodide_bundle.py
new file mode 100644
index 0000000..723f72a
--- /dev/null
+++ b/scripts/build_pyodide_bundle.py
@@ -0,0 +1,50 @@
+"""Bundle the pure-Python `pir` package + flagship examples into a zip.
+
+The zip is loaded directly in the browser with `pyodide.unpackArchive`, so the
+Pyodide playground runs the *real* example code with no build step and without
+pulling matplotlib (the headless loop path is numpy-only). Re-run this whenever
+`pir/` or a bundled example changes; the output is committed so GitHub Pages can
+serve it.
+
+Usage:
+ python3 scripts/build_pyodide_bundle.py
+"""
+
+from __future__ import annotations
+
+import zipfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / "docs" / "pyodide" / "pir_bundle.zip"
+
+# Examples exposed to the browser PoC. Add to this list as more flagship loops
+# get a JS renderer (see docs/pyodide_playground_strategy.md).
+BUNDLED_EXAMPLES = [
+ "examples/manipulation/01_pick_and_retry.py",
+]
+
+
+def iter_pir_sources():
+ for path in sorted((ROOT / "pir").rglob("*.py")):
+ if "__pycache__" in path.parts:
+ continue
+ yield path
+
+
+def build() -> Path:
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ files = list(iter_pir_sources()) + [ROOT / rel for rel in BUNDLED_EXAMPLES]
+
+ with zipfile.ZipFile(OUT, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for path in files:
+ arcname = path.relative_to(ROOT).as_posix()
+ zf.write(path, arcname)
+
+ size_kb = OUT.stat().st_size / 1024
+ print(f"wrote {OUT.relative_to(ROOT)} ({len(files)} files, {size_kb:.1f} KB)")
+ return OUT
+
+
+if __name__ == "__main__":
+ build()
diff --git a/tests/test_pyodide_bundle.py b/tests/test_pyodide_bundle.py
new file mode 100644
index 0000000..b7929b7
--- /dev/null
+++ b/tests/test_pyodide_bundle.py
@@ -0,0 +1,75 @@
+"""Guard the committed Pyodide bundle against drift from the source tree.
+
+The browser PoC (docs/pyodide/poc.html) runs the real `pir` code by unpacking
+docs/pyodide/pir_bundle.zip. If `pir/` or a bundled example changes but the zip
+is not rebuilt, the browser would silently run stale code. These tests fail in
+that case and tell you to re-run scripts/build_pyodide_bundle.py.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import zipfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+BUNDLE = ROOT / "docs" / "pyodide" / "pir_bundle.zip"
+
+
+def _load_builder():
+ spec = importlib.util.spec_from_file_location(
+ "build_pyodide_bundle", ROOT / "scripts" / "build_pyodide_bundle.py"
+ )
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_bundle_exists() -> None:
+ assert BUNDLE.exists(), "run: python3 scripts/build_pyodide_bundle.py"
+
+
+def test_bundle_contents_match_source() -> None:
+ builder = _load_builder()
+ expected = {p.relative_to(ROOT).as_posix() for p in builder.iter_pir_sources()}
+ expected |= set(builder.BUNDLED_EXAMPLES)
+
+ with zipfile.ZipFile(BUNDLE) as zf:
+ archived = {info.filename: zf.read(info.filename) for info in zf.infolist()}
+
+ assert set(archived) == expected, (
+ "bundle file list is stale; re-run python3 scripts/build_pyodide_bundle.py"
+ )
+
+ for arcname, data in archived.items():
+ on_disk = (ROOT / arcname).read_bytes()
+ assert data == on_disk, (
+ f"{arcname} differs from source; re-run python3 scripts/build_pyodide_bundle.py"
+ )
+
+
+def test_bundled_example_runs_headless_without_matplotlib() -> None:
+ """The browser path imports numpy only; prove the loop never needs matplotlib."""
+ import sys
+
+ class _Block:
+ def find_spec(self, name, path=None, target=None):
+ if name == "matplotlib" or name.startswith("matplotlib."):
+ raise ImportError("blocked for test")
+ return None
+
+ blocker = _Block()
+ sys.meta_path.insert(0, blocker)
+ try:
+ example = ROOT / "examples" / "manipulation" / "01_pick_and_retry.py"
+ spec = importlib.util.spec_from_file_location("pick_and_retry_pyodide", example)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ trace = module.run(seed=3, render=False)
+ summary = trace.summary()
+ assert summary.success
+ assert summary.failure_counts.get("grasp_miss", 0) >= 1
+ finally:
+ sys.meta_path.remove(blocker)