diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e836939..6e4d612 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,6 +5,8 @@ on: branches: ["main"] paths: - "docs/**" + - "pir/**" + - "scripts/build_pyodide_bundle.py" - ".github/workflows/pages.yml" workflow_dispatch: @@ -29,6 +31,14 @@ jobs: - name: Check out repository uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Rebuild Pyodide bundle from source + run: python3 scripts/build_pyodide_bundle.py + - name: Configure GitHub Pages uses: actions/configure-pages@v5 diff --git a/docs/pyodide/pir_bundle.zip b/docs/pyodide/pir_bundle.zip new file mode 100644 index 0000000..72db0f8 Binary files /dev/null and b/docs/pyodide/pir_bundle.zip differ diff --git a/docs/pyodide/poc.html b/docs/pyodide/poc.html new file mode 100644 index 0000000..7f0578c --- /dev/null +++ b/docs/pyodide/poc.html @@ -0,0 +1,150 @@ + + + + + +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)