From 014831933ba8fcdccbca64e2a70519782f69beec Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Thu, 4 Jun 2026 16:55:01 +0900 Subject: [PATCH 1/2] Reframe README around "tiny robot failure lab" and add a 10-lesson tour Sharpen the project's entry point so the failure-aware closed-loop idea lands in the first screen instead of being buried under 39 examples. - README: lead with "tutorials assume actions succeed; this teaches what happens when they don't", a single hero GIF (pick_and_retry), an 8-line annotated core loop, a browser-run link, and three starter loops. Move the 39-example status, Colab list, and star CTA below the fold. Drop the duplicate core-loop snippet further down. - lessons/README.md: a failure-first, 10-lesson guided tour that curates existing tested examples (bare loop -> fail/retry -> replan -> recovery -> belief -> active perception -> safety -> uncertainty -> clarify -> capstone), each with what-fails / what-it-observes / how-belief-changes notes. Linked from the README. Curates rather than copies so the lessons stay in sync with the tested examples. - docs/release_notes_v0.1.0.md: draft body + pre-publish checklist for the first public "Tiny Robot Failure Lab" release. All ten lesson commands verified headless; markdown/playground asset tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 100 ++++++++--------- docs/release_notes_v0.1.0.md | 106 ++++++++++++++++++ lessons/README.md | 211 +++++++++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 50 deletions(-) create mode 100644 docs/release_notes_v0.1.0.md create mode 100644 lessons/README.md diff --git a/README.md b/README.md index 0d340ee..6b94ae2 100644 --- a/README.md +++ b/README.md @@ -5,27 +5,38 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) ![Core dependencies](https://img.shields.io/badge/core-numpy%20%2B%20matplotlib-orange) -**Robots observe, act, fail, retry, update beliefs, and replan.** -This repo shows that loop in small, readable Python — no ROS, no GPU, no -simulator. Just `numpy + matplotlib`. - -[Open the example gallery](https://rsasaki0109.github.io/PythonInteractiveRobotics/), -[try the live playground](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html), or open a -[shareable live trace](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html?scenario=household&answer=red&compare=1&autoplay=1), -or jump straight into the first runnable loop below. You can also run the -flagship loops directly in Colab: -[pick and retry](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/pick_and_retry.ipynb), -[safety filter](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/safety_filter_cbf.ipynb), and -[human correction replanning](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/human_correction_replanning.ipynb). -For language ambiguity, try -[clarifying question](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/clarifying_question.ipynb), or run the integrated -[household task agent](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/household_task_agent.ipynb). -If the project helps you teach, prototype, or explain robotics loops, a GitHub -star helps others find it. +### A tiny robot failure lab -| Avoiding | Reaching under occlusion | Mapping while uncertain | -| --- | --- | --- | -| ![A point robot's naive go-to-goal velocity is projected onto a CBF safe set at every step. The policy itself never knows the obstacles exist - a separate runtime safety filter slides it around them.](docs/assets/gifs/safety_filter_cbf.gif) | ![A 2-link arm predicts a briefly occluded moving target, keeps servoing through the occlusion, and reaches the intercept point when the target reappears.](docs/assets/gifs/moving_target_reaching.gif) | ![A toy active-SLAM agent shrinks pose belief and occupancy belief at the same time, by picking moves that maximize expected entropy drop.](docs/assets/gifs/active_slam_toy.gif) | +**Robotics tutorials often assume actions succeed. This repo teaches what +happens when they do not.** + +Watch a robot miss a grasp, update its belief, and recover — in pure Python. +No ROS. No GPU. No simulator. Just `numpy + matplotlib`. + +![A tabletop robot misses a grasp, updates its belief over where the object is, and retries until it succeeds.](docs/assets/gifs/pick_and_retry.gif) + +[▶ Run in your browser](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html?scenario=household&answer=red&compare=1&autoplay=1) + · [Start with `01_pick_and_retry.py`](#try-it) + · [Take the 10-lesson tour](lessons/README.md) + · [Browse all loops](https://rsasaki0109.github.io/PythonInteractiveRobotics/) + +The whole repo is one loop, written out small enough to read: + +```python +obs = env.reset(seed=0) +agent.reset() + +for t in range(max_steps): + action = agent.act(obs) # think + obs, reward, done, info = env.step(action) # act + agent.update(obs, reward, info) # observe failure, update belief + if done: + break # ...else replan and retry +``` + +`info["failure"]` is a first-class part of that loop — grasp misses, occlusion, +localization drift, blocked paths. The interesting behaviour is what the robot +does *after* it fails. ## Try it @@ -39,23 +50,26 @@ python3 examples/manipulation/01_pick_and_retry.py A tiny tabletop robot misses a grasp, updates its belief, and retries — in under 5 seconds. Core dependencies are `numpy` and `matplotlib` only. -For an even smaller first loop: - -```bash -python3 examples/runtime/01_sense_act_loop.py -``` - -## Start Here +## Three loops to start with | If you want to see | Run | What it teaches | | --- | --- | --- | | Failure recovery | `python3 examples/manipulation/01_pick_and_retry.py` | grasp miss -> belief update -> retry | -| Runtime safety | `python3 examples/navigation/29_safety_filter_cbf.py` | nominal controller -> CBF projection -> safe motion | -| Active perception | `python3 examples/navigation/07_active_slam_toy.py` | map and pose uncertainty -> information-seeking action | -| Shareable live trace | [Try live](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html?scenario=household&answer=red&compare=1&autoplay=1) | belief entropy, compare mode, and failure timeline | -| Human correction | [Open in Colab](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/human_correction_replanning.ipynb) | shortcut -> human correction -> cost update -> replan | -| Language ambiguity | [Open in Colab](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/clarifying_question.ipynb) | ambiguous command -> ask question -> answer -> act | -| Integrated household task | [Open in Colab](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/household_task_agent.ipynb) | clarify -> plan -> safety check -> retry -> human replan | +| Online replanning | `python3 examples/navigation/04_online_replanning_astar.py` | plan -> hit a hidden wall -> replan | +| Asking for help | `python3 examples/embodied_ai/35_clarifying_question.py "pick the block" --answer red` | ambiguous command -> ask -> act | + +Prefer the browser? Try the [live playground](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html) +(belief entropy, compare mode, failure timeline) — every run is a +[Shareable live trace](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html?scenario=household&answer=red&compare=1&autoplay=1) +you can link to — or open the flagship loops in Colab: +[pick and retry](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/pick_and_retry.ipynb), +[safety filter](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/safety_filter_cbf.ipynb), +[human correction replanning](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/human_correction_replanning.ipynb), +[clarifying question](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/clarifying_question.ipynb), or the integrated +[household task agent](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/household_task_agent.ipynb). + +If the project helps you teach, prototype, or explain robotics loops, a GitHub +star helps others find it. ## Status @@ -202,24 +216,10 @@ python scripts/run_all_smoke_tests.py --gifs --check-gifs CI runs the same smoke suite and GIF checks on Python 3.10, 3.11, and 3.12. -## Core idea - -```python -obs = env.reset(seed=0) -agent.reset() - -for t in range(max_steps): - action = agent.act(obs) - obs, reward, done, info = env.step(action) - agent.update(obs, reward, info) - env.render() - - if done: - break -``` +## Inspecting a run -The goal is not photorealism. -The goal is to understand the perception-action loop. +The goal is not photorealism. It is to understand the perception-action loop +shown at the top of this README — and to see the internal state that drives it. Every example returns a `Trace`, so headless runs can be inspected without rendering. See `docs/trace.md` for the full trace contract. diff --git a/docs/release_notes_v0.1.0.md b/docs/release_notes_v0.1.0.md new file mode 100644 index 0000000..6b712f1 --- /dev/null +++ b/docs/release_notes_v0.1.0.md @@ -0,0 +1,106 @@ +# Release draft — v0.1.0 "Tiny Robot Failure Lab" + +> Draft for the first public GitHub Release. Paste the body below into the +> release on the `v0.1.0` tag. Keep it a story, not a changelog. Cut anything +> that reads like an internal status report. + +**Tag:** `v0.1.0`  ·  **Title:** `v0.1.0 — Tiny Robot Failure Lab` + +--- + +## Release body (copy from here) + +### Robotics tutorials usually assume actions succeed. This one is about what happens when they don't. + +Real robots miss grasps, drive into walls they couldn't see, lose track of where +they are, and misread ambiguous commands. PythonInteractiveRobotics is a tiny +lab for exactly that part of robotics — **observe, act, fail, update your +belief, replan, retry** — in readable Python with no ROS, no GPU, and no +simulator. Just `numpy + matplotlib`. + +**What's in this first release** + +A failure-first course of **10 short, runnable loops**, in order, plus 39 total +examples to branch into. Start here: + +```bash +git clone https://github.com/rsasaki0109/PythonInteractiveRobotics.git +cd PythonInteractiveRobotics +python3 -m pip install -e . +python3 examples/manipulation/01_pick_and_retry.py +``` + +A tabletop robot misses a grasp, updates its belief, and retries — in under 5 +seconds. + +**Try it without installing** + +- ▶ **Run in your browser:** the [live playground](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html) + with belief entropy, compare mode, and a failure timeline. +- 📓 **Open in Colab:** [pick and retry](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/pick_and_retry.ipynb), + [safety filter](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/safety_filter_cbf.ipynb), + [household task agent](https://colab.research.google.com/github/rsasaki0109/PythonInteractiveRobotics/blob/main/notebooks/household_task_agent.ipynb). +- 🎓 **Take the tour:** the [10-lesson failure-first course](https://github.com/rsasaki0109/PythonInteractiveRobotics/blob/main/lessons/README.md). + +**Highlights** + +- **Fail and retry** — grasp miss → belief update → retry (`manipulation/01`) +- **Replan around a hidden wall** — plan → see new obstacle → replan (`navigation/04`) +- **Act to learn** — toy active SLAM that moves to shrink uncertainty (`navigation/07`) +- **Stay safe at runtime** — a CBF safety filter the policy never knows about (`navigation/29`) +- **Ask before acting** — ambiguous command → clarify → act (`embodied_ai/35`) +- **Put it together** — a household agent that clarifies, plans, stays safe, + retries, and replans in one run (`embodied_ai/36`) + +Every example exposes failure through `info["failure"]` and returns an +inspectable `Trace`, so you can study a run headless without rendering. + +**Under the hood** + +- 39 runnable examples · 38 generated GIFs · 5 Colab notebooks +- 111 smoke / regression tests · CI green on Python 3.10, 3.11, and 3.12 +- Core deps: `numpy` + `matplotlib` only; optional Gymnasium-style adapters and + ROS2 / simulator bridge docs for when you outgrow the toy worlds + +**Where this sits** + +Not a replacement for ROS2, MoveIt, MuJoCo, Isaac Sim, or LeRobot — and not a +benchmark. Think of it as the missing closed-loop chapter you read *after* +algorithm textbooks like PythonRobotics and *before* a heavy stack: a small, +debuggable model of failure, belief, recovery, and replanning. + +**Contribute** + +Good first contributions are deliberately small: + +- add a new **failure mode** to an existing world +- add a **one-file lesson** in the failure-first style +- improve a **trace story / GIF** + +See [`CONTRIBUTING.md`](https://github.com/rsasaki0109/PythonInteractiveRobotics/blob/main/CONTRIBUTING.md). +If this helped you learn, teach, or prototype, a ⭐ helps others find it. + +--- + +## Pre-publish checklist + +Before tagging `v0.1.0`, confirm the launch surface is ready (see also +`docs/public_launch.md`): + +- [ ] README top renders the hero GIF, the 8-line loop, and the browser link in + the first screen +- [ ] `lessons/README.md` links resolve on GitHub and all 10 commands run +- [ ] CI green on `main` for 3.10 / 3.11 / 3.12 +- [ ] GitHub repo "social preview" image set (Settings → General → Social preview) +- [ ] Playground page loads from GitHub Pages +- [ ] Tag the release: `git tag -a v0.1.0 -m "Tiny Robot Failure Lab" && git push origin v0.1.0` + +## How to cut the release + +```bash +# from a clean main with everything pushed +git tag -a v0.1.0 -m "Tiny Robot Failure Lab" +git push origin v0.1.0 +# then create the GitHub Release on that tag and paste the body above, +# or: gh release create v0.1.0 --title "v0.1.0 — Tiny Robot Failure Lab" --notes-file <(...) +``` diff --git a/lessons/README.md b/lessons/README.md new file mode 100644 index 0000000..d9c9fde --- /dev/null +++ b/lessons/README.md @@ -0,0 +1,211 @@ +# Lessons: a failure-first tour of robot loops + +Most robotics tutorials stop at `action = plan(observation)` and assume the +action works. Real robots miss grasps, hit walls they could not see, lose track +of where they are, and misread ambiguous commands. **This course is ten short +loops, in order, about what a robot does _after_ something goes wrong.** + +Each lesson is one runnable example you already have in this repo — no new +setup. Read the file top to bottom (every one fits on a screen or two), run it, +and watch the internal state change. Do them in order: each lesson assumes the +one before it. + +```bash +python3 -m pip install -e . +``` + +| # | Lesson | Run | The loop it teaches | +| --: | --- | --- | --- | +| 1 | The bare loop | `python3 examples/runtime/01_sense_act_loop.py` | observe → act → observe | +| 2 | Fail and retry | `python3 examples/manipulation/01_pick_and_retry.py` | grasp miss → belief update → retry | +| 3 | Replan around a hidden wall | `python3 examples/navigation/04_online_replanning_astar.py` | plan → see new obstacle → replan | +| 4 | Recover from a blocked path | `python3 examples/navigation/09_blocked_path_recovery.py` | step into a dead end → back off → replan | +| 5 | Act on a belief, not the truth | `python3 examples/navigation/06_belief_based_navigation.py` | estimate pose → decide under uncertainty | +| 6 | Act to learn (active perception) | `python3 examples/navigation/07_active_slam_toy.py` | pick moves that shrink uncertainty | +| 7 | Stay safe at runtime | `python3 examples/navigation/29_safety_filter_cbf.py` | unsafe command → safety filter → safe motion | +| 8 | Know when you don't know | `python3 examples/manipulation/30_conformal_ask_for_help.py` | ambiguous → ask for help instead of guessing | +| 9 | Ask before acting | `python3 examples/embodied_ai/35_clarifying_question.py "pick the block" --answer red` | ambiguous command → clarify → act | +| 10 | Put it all together | `python3 examples/embodied_ai/36_household_task_agent.py "put the block away" --answer red` | clarify → plan → stay safe → retry → replan | + +Most examples accept `--no-render` for a fast headless run, and every example +returns a `Trace` you can inspect (see [`docs/trace.md`](../docs/trace.md)). + +--- + +## Lesson 1 — The bare loop + +**Run:** `python3 examples/runtime/01_sense_act_loop.py` +([source](../examples/runtime/01_sense_act_loop.py)) + +The smallest closed-loop example: a robot observes a noisy state, acts, and +observes again. Nothing fails yet — this is the skeleton every later lesson +hangs on. + +- **What it observes:** a noisy estimate of its own state, every step. +- **What changes:** nothing surprising — that's the point. Establish the + `observe → act → observe` rhythm before failure enters. +- **Concept:** the perception–action loop is the unit of robotics, not a single + planning call. + +→ Next: a loop where the action can fail. + +## Lesson 2 — Fail and retry + +**Run:** `python3 examples/manipulation/01_pick_and_retry.py` +([source](../examples/manipulation/01_pick_and_retry.py)) + +A tabletop robot picks an object, **misses**, updates its belief about where the +object really is, and retries with a different grasp. This is the thesis of the +whole repo in one file. + +- **What fails:** the grasp, because the perceived object pose is biased. +- **What it observes:** `info["failure"]` reports the miss; each attempt yields a + fresh, noisy detection. +- **How belief changes:** a running estimate of the object pose is updated after + every miss, so the next attempt aims somewhere new. +- **Concept:** failure is data. The interesting behaviour is the belief update + between attempts. + +→ Next: the same idea, but the failure is a wall instead of a grasp. + +## Lesson 3 — Replan around a hidden wall + +**Run:** `python3 examples/navigation/04_online_replanning_astar.py` +([source](../examples/navigation/04_online_replanning_astar.py)) + +The robot plans a path with A* through a map it only partially knows, drives +into a previously unknown wall, and **replans** from what it just learned. + +- **What fails:** the current plan, when an obstacle is observed that the map did + not contain. +- **What it observes:** new occupied cells as it moves. +- **How belief changes:** the occupancy map is updated, then A* is re-run on the + new map. +- **Concept:** a plan is a hypothesis. Replanning is how a robot survives an + imperfect map. + +→ Next: what to do when replanning alone isn't enough and you're stuck. + +## Lesson 4 — Recover from a blocked path + +**Run:** `python3 examples/navigation/09_blocked_path_recovery.py` +([source](../examples/navigation/09_blocked_path_recovery.py)) + +The robot detects that the path ahead is blocked, **backs off**, marks the +blocked cell, and replans around it — recovery, not just replanning in place. + +- **What fails:** forward progress; the robot would otherwise be wedged. +- **What it observes:** a blocked cell directly ahead. +- **How belief changes:** the blocked cell is committed to the map so the + replan does not re-suggest it. +- **Concept:** recovery is an explicit behaviour — detect, undo, record, retry. + +→ Next: stop assuming you even know where you are. + +## Lesson 5 — Act on a belief, not the truth + +**Run:** `python3 examples/navigation/06_belief_based_navigation.py` +([source](../examples/navigation/06_belief_based_navigation.py)) + +The robot no longer has direct access to its true pose. It maintains a belief +(a heatmap), estimates where it probably is, and navigates from that estimate. + +- **What it observes:** noisy, partial cues about its position. +- **How belief changes:** a belief distribution over poses is updated each step; + the chosen action depends on the estimate, not the ground truth. +- **Concept:** under partial observability you act on a belief. The true state is + shown only so you can see how wrong the belief sometimes is. + +→ Next: choose actions specifically to make that belief sharper. + +## Lesson 6 — Act to learn (active perception) + +**Run:** `python3 examples/navigation/07_active_slam_toy.py` +([source](../examples/navigation/07_active_slam_toy.py)) + +A toy active-SLAM agent picks moves that **reduce** both pose and map +uncertainty — it acts in order to perceive better, not just to reach a goal. + +- **What it observes:** measurements whose value depends on where it chooses to go. +- **How belief changes:** moves are scored by expected uncertainty (entropy) + reduction, so the agent seeks informative actions. +- **Concept:** perception is something you can plan for. Information is a reward. + +→ Next: a hard runtime guarantee layered on top of any policy. + +## Lesson 7 — Stay safe at runtime + +**Run:** `python3 examples/navigation/29_safety_filter_cbf.py` +([source](../examples/navigation/29_safety_filter_cbf.py)) + +A naive go-to-goal controller produces velocities that would hit obstacles. A +**control-barrier-function** safety filter projects each command onto a safe +set — the policy never even knows the obstacles exist. + +- **What fails (and is prevented):** collisions the nominal controller would + cause. +- **What it observes:** obstacle geometry, used by the filter, not the policy. +- **How behaviour changes:** every command is minimally adjusted to stay safe. +- **Concept:** safety can be a separate runtime layer, independent of how smart + the policy is. + +→ Next: instead of guarding a confident policy, handle an unconfident one. + +## Lesson 8 — Know when you don't know + +**Run:** `python3 examples/manipulation/30_conformal_ask_for_help.py` +([source](../examples/manipulation/30_conformal_ask_for_help.py)) + +A sorter calibrates a **conformal prediction set** offline. At runtime it acts +only when the set is a single confident label, and **asks a toy oracle for help** +when the set is ambiguous. + +- **What fails (and is avoided):** confident-but-wrong actions on ambiguous input. +- **What it observes:** a prediction set whose size reflects uncertainty. +- **How behaviour changes:** singleton → act; ambiguous → ask. +- **Concept:** calibrated uncertainty turns "I'm not sure" into a concrete + request for help. + +→ Next: the help request becomes a natural-language question. + +## Lesson 9 — Ask before acting + +**Run:** `python3 examples/embodied_ai/35_clarifying_question.py "pick the block" --answer red` +([source](../examples/embodied_ai/35_clarifying_question.py)) + +Given the ambiguous command *"pick the block"* with several blocks present, the +robot **asks which one**, takes the answer, resolves the goal, and acts. + +- **What fails (and is avoided):** acting on an under-specified command. +- **What it observes:** multiple candidate objects matching the command. +- **How behaviour changes:** detect ambiguity → ask → bind the answer → act. +- **Concept:** language is just another noisy observation; clarification is a + belief update driven by a human. + +→ Next: combine clarify, plan, safety, retry, and replan in one agent. + +## Lesson 10 — Put it all together + +**Run:** `python3 examples/embodied_ai/36_household_task_agent.py "put the block away" --answer red` +([source](../examples/embodied_ai/36_household_task_agent.py)) + +The capstone. A household robot **clarifies** an ambiguous command, **plans** +through a room, **rejects an unsafe step**, **retries** a missed grasp, accepts a +**human correction** and **replans**, then stores the block. + +- **What fails:** ambiguity, an unsafe floor step, and a grasp miss — all in one + run. +- **How behaviour changes:** every earlier lesson shows up as one stage of this + pipeline. +- **Concept:** "embodied intelligence" here is not one big model — it is these + small failure-aware loops, composed. + +→ Done. From here, branch into the full +[example index](../examples/README.md) or the deeper +[learning paths](../docs/learning_paths.md). + +--- + +Found a failure mode these lessons don't cover yet? That's a great +contribution — see [`CONTRIBUTING.md`](../CONTRIBUTING.md). If this course helped +you learn or teach, a GitHub star helps other people find it. From 40f7228dd8681ac46fac51f100e10d03b8c1ad65 Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Thu, 4 Jun 2026 22:16:55 +0900 Subject: [PATCH 2/2] Add naive-vs-failure-aware hero GIF and a Pyodide playground design memo - scripts/make_hero_compare_gif.py: generate a side-by-side hero GIF on the same Tabletop2D task with the same seed. Left runs a "no belief update" baseline that locks onto its first guess and keeps grabbing the same spot; right runs the repo's PickAndRetryAgent, which looks, updates its belief, and recovers. The baseline fails on ~63% of seeds (not a cherry-picked fluke); seed 3 shows the common case: naive gives up after 8 misses, failure-aware recovers in 3. A --search mode scans seeds. - docs/assets/gifs/naive_vs_failure_aware.gif: the rendered hero (passes the existing --check-gifs frame/nonblank checks). - README: lead with the comparison hero instead of the single pick GIF, with a caption framing the gap as the point of the repo. - docs/pyodide_playground_strategy.md: design memo for running the real Python loops in the browser. Notes that today's playground.js is a JS reimplementation (drift risk) and proposes a "Python computes, JS draws" split so Pyodide reduces drift instead of adding a surface; phased plan + risks. asset tests and --check-gifs green. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- docs/assets/gifs/naive_vs_failure_aware.gif | Bin 0 -> 66163 bytes docs/pyodide_playground_strategy.md | 125 +++++++++ scripts/make_hero_compare_gif.py | 266 ++++++++++++++++++++ 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 docs/assets/gifs/naive_vs_failure_aware.gif create mode 100644 docs/pyodide_playground_strategy.md create mode 100644 scripts/make_hero_compare_gif.py diff --git a/README.md b/README.md index 6b94ae2..c714227 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ happens when they do not.** Watch a robot miss a grasp, update its belief, and recover — in pure Python. No ROS. No GPU. No simulator. Just `numpy + matplotlib`. -![A tabletop robot misses a grasp, updates its belief over where the object is, and retries until it succeeds.](docs/assets/gifs/pick_and_retry.gif) +![Side by side on the same tabletop task. Left, a naive picker locks onto its first guess and keeps grabbing the same empty spot until it gives up after eight misses. Right, a failure-aware agent looks from a better viewpoint, updates its belief about where the object is, and recovers the grasp in three tries.](docs/assets/gifs/naive_vs_failure_aware.gif) + +*Same task, same seed. Left: a naive picker that never updates its guess keeps +missing and gives up. Right: the failure-aware agent looks, updates its belief, +and recovers. That gap is the whole repo.* [▶ Run in your browser](https://rsasaki0109.github.io/PythonInteractiveRobotics/playground.html?scenario=household&answer=red&compare=1&autoplay=1) · [Start with `01_pick_and_retry.py`](#try-it) diff --git a/docs/assets/gifs/naive_vs_failure_aware.gif b/docs/assets/gifs/naive_vs_failure_aware.gif new file mode 100644 index 0000000000000000000000000000000000000000..e7cb423a35e21527fd775f7127451a8d2d573d26 GIT binary patch literal 66163 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 Trace ──▶ trace JSON ──▶ current scene / + trace.summary() / per-step records (postMessage) belief / timeline + renderer +``` + +- The Python side runs the **real** `examples/.../*.py` loop headless + (`render=False`) and returns the `Trace` (already a first-class, tested object + — see [`docs/trace.md`](trace.md)). +- A thin serializer turns the `Trace` (obs/action/info/reward per step) into the + JSON shape the current `playground.js` renderer already consumes. +- The JS reimplementation of dynamics gets **deleted**; JS keeps only drawing. + +This keeps the browser bundle small (no matplotlib), makes the playground a true +mirror of the tested code, and removes the drift problem instead of adding to it. + +Fallback option (heavier): import `matplotlib` in Pyodide and blit Agg PNG frames +to a ``. Simpler to wire (reuses `env.render`), but a multi-MB download +and janky first paint. Keep this only as a stopgap for an example whose JSON +renderer is not ready yet. + +## Packaging + +The loops only need `pir` + `numpy`. Options, simplest first: + +1. **Load `pir` as source over the network.** `micropip` or Pyodide's + `loadPackage("numpy")` for numpy, then fetch the handful of `pir/**.py` files + (or a generated single-file bundle) and write them into Pyodide's virtual FS. + No build step; works from GitHub Pages. +2. **Ship a wheel.** `python -m build`, host `pir-0.1.0-py3-none-any.whl` under + `docs/`, `micropip.install("./pir-...whl")`. Cleaner import story; adds a + release artifact to keep current. + +Start with (1) for the first flagship, move to (2) if import wiring gets noisy. +`numpy` has a prebuilt Pyodide package, so no compilation is needed. + +## The 5 flagship loops (and their render shape) + +| Example | Renderer needed | Notes | +| --- | --- | --- | +| `manipulation/01_pick_and_retry` | tabletop (continuous) | best first target; the hero story, small state | +| `navigation/04_online_replanning_astar` | grid + path | reuses grid renderer; shows replanning | +| `navigation/29_safety_filter_cbf` | continuous + obstacles | needs vector overlay (nominal vs safe u) | +| `navigation/07_active_slam_toy` | grid + belief heatmap | reuses belief panel | +| `embodied_ai/35_clarifying_question` | already in JS today | swap JS dynamics for real Python first | + +Two render families cover all five: **grid** (already drawn today) and +**tabletop/continuous** (small addition). Build those two renderers once. + +## 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 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 +exists). Python produces the trace; JS draws it; delete the JS dynamics for that +scenario. This is the first honest "real Python in your browser" claim. + +**Phase 2 — tabletop renderer + hero loop (1–2 days).** Add the continuous +tabletop renderer and wire `pick_and_retry`. Now the README hero GIF has a +"run it yourself" twin. + +**Phase 3 — editable code cell (1–2 days).** Expose the agent's `act()` in a +small editor so visitors can tweak the retry/belief logic and re-run. This is +the "wow, I can edit the robot's brain in the browser" moment that converts to +stars. + +Ship Phase 0–1 behind the existing playground before any Hacker News launch; +Phases 2–3 can follow the launch. + +## Risks / watch-list + +- **First-load latency.** Pyodide core is a few MB. Lazy-load it only when the + user clicks "Run real Python"; keep the instant JS-rendered preview as the + default first paint. Cache aggressively. +- **No silent matplotlib import.** If any example imports matplotlib at module + top level, the headless path drags it in. Keep example imports of matplotlib + lazy (inside `render`/`main`), as `01_pick_and_retry.py` already does. +- **Trace serialization is the contract.** Add a `tests/` check that the JSON + serializer covers every field the JS renderer reads, so Python and browser + cannot drift — this is the guard that makes Pyodide *reduce* drift rather than + add a new surface. +- **Keep it optional.** Pyodide is a `docs/` concern only. It must never become + a core dependency or touch the 5-second local first-run. + +## Definition of done (item ②) + +- One flagship loop runs its **unmodified** Python `run(...)` in the browser. +- The JS reimplementation of that scenario's dynamics is deleted. +- First paint stays instant (Pyodide lazy-loaded on demand). +- A test pins the trace-JSON contract shared by Python and JS. diff --git a/scripts/make_hero_compare_gif.py b/scripts/make_hero_compare_gif.py new file mode 100644 index 0000000..af15f93 --- /dev/null +++ b/scripts/make_hero_compare_gif.py @@ -0,0 +1,266 @@ +"""Generate the README hero GIF: a naive picker vs a failure-aware picker. + +Left panel: a naive agent that grabs at whatever it currently sees, never + moves the camera (so it stays behind the occluder), never keeps a + belief, and never adapts its retry. It keeps missing. +Right panel: the repo's PickAndRetryAgent, which looks from better viewpoints, + averages noisy detections into a belief, and retries differently + after each miss. It recovers and succeeds. + +Both panels run the same Tabletop2D world with the same seed, so the contrast is +the policy, not luck. This is a curated marketing asset, kept separate from the +per-example pipeline in scripts/make_gifs.py. + +Usage: + python3 scripts/make_hero_compare_gif.py + python3 scripts/make_hero_compare_gif.py --search # scan seeds for a clean story +""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import imageio.v2 as imageio +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +from matplotlib.patches import Circle, Rectangle + +from pir.core.types import Failure +from pir.worlds.tabletop_2d import Tabletop2D + +OUT_DIR = ROOT / "docs" / "assets" / "gifs" +GIF_NAME = "naive_vs_failure_aware.gif" + + +def load_example(relative_path: str) -> ModuleType: + path = ROOT / relative_path + spec = importlib.util.spec_from_file_location(path.stem, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class NaiveAgent: + """The "no belief update" baseline. + + It locks onto the very first detection it ever sees and keeps grabbing at + that same stale point forever. It never re-observes, never averages, never + moves the camera, and never adapts after a miss. The contrast with the + failure-aware agent is therefore structural (update vs. don't update), not + a matter of luck on a particular seed. + """ + + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + # Keep a belief_mean attribute so the renderer can stay uniform, but the + # naive agent never maintains a real belief. + self.belief_mean: np.ndarray | None = None + self.belief_radius = 0.0 + self._locked_target: np.ndarray | None = None + + def act(self, obs: dict[str, Any]) -> dict[str, Any]: + if self._locked_target is None: + detections = obs.get("detections", []) + if detections: + self._locked_target = np.asarray(detections[0]["position"], dtype=float) + else: + # Nothing seen yet: commit to a blind guess and never revise it. + self._locked_target = np.array([0.5, 0.5], dtype=float) + return {"type": "pick", "position": np.clip(self._locked_target, 0.0, 1.0)} + + def update(self, obs: dict[str, Any], reward: float, info: dict[str, Any]) -> None: + # The whole point: the naive agent learns nothing from a miss. + return None + + +def fig_to_frame(fig: plt.Figure) -> np.ndarray: + fig.canvas.draw() + width, height = fig.canvas.get_width_height() + buffer = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8) + return buffer.reshape((height, width, 4))[:, :, :3].copy() + + +def run_episode(agent_factory, seed: int, max_steps: int) -> list[dict[str, Any]]: + """Run one episode and return a per-frame record of (env snapshot, info).""" + env = Tabletop2D(seed=seed) + agent = agent_factory() + obs = env.reset(seed=seed) + agent.reset() + + records: list[dict[str, Any]] = [{"env": env, "agent": agent, "info": {}}] + for _ in range(max_steps): + action = agent.act(obs) + result = env.step(action) + obs, reward, done, info = result.as_tuple() + agent.update(obs, reward, info) + # Snapshot the mutable state we need for rendering this frame. + records.append( + { + "camera": env.camera_pos.copy(), + "last_detection": None if env.last_detection is None else env.last_detection.copy(), + "picked": env.obj.picked, + "attempts": env.attempts, + "belief_mean": None if getattr(agent, "belief_mean", None) is None else np.asarray(agent.belief_mean).copy(), + "belief_radius": float(getattr(agent, "belief_radius", 0.0)), + "info": info, + } + ) + if done: + break + return records + + +def episode_outcome(records: list[dict[str, Any]]) -> tuple[bool, int]: + picked = any(r.get("picked") for r in records[1:]) + attempts = max((r.get("attempts", 0) for r in records[1:]), default=0) + return picked, attempts + + +def build_frames(seed: int, max_steps: int) -> list[np.ndarray]: + naive_module_agent = NaiveAgent + pick_module = load_example("examples/manipulation/01_pick_and_retry.py") + + naive = run_episode(lambda: naive_module_agent(), seed=seed, max_steps=max_steps) + smart = run_episode(lambda: pick_module.PickAndRetryAgent(), seed=seed, max_steps=max_steps) + + n = max(len(naive), len(smart)) + + frames: list[np.ndarray] = [] + for i in range(n): + ln = naive[min(i, len(naive) - 1)] + ls = smart[min(i, len(smart) - 1)] + + fig, (axl, axr) = plt.subplots(1, 2, figsize=(8.6, 4.5), dpi=90) + fig.suptitle( + "Most robotics tutorials assume the grasp works. Real robots miss — and recover.", + fontsize=11, + ) + + for ax, rec, title, color in ( + (axl, ln, "Naive: grab at what you see", "tab:red"), + (axr, ls, "Failure-aware: look → update belief → retry", "tab:green"), + ): + _render_record(ax, rec, title, color) + + fig.tight_layout(rect=(0, 0, 1, 0.94)) + frames.append(fig_to_frame(fig)) + plt.close(fig) + + # Hold the final frame so the outcome reads clearly. + frames.extend([frames[-1]] * 4) + return frames + + +# A lightweight stand-in Tabletop2D geometry shared by both panels. +_OCCLUDER = np.array([0.43, 0.42, 0.57, 0.68], dtype=float) +_OBJ_POS = np.array([0.64, 0.54], dtype=float) +_OBJ_RADIUS = 0.045 + + +def _render_record(ax: plt.Axes, rec: dict[str, Any], title: str, color: str) -> None: + ax.set_title(title, fontsize=10.5, color=color, fontweight="bold") + ax.set_xlim(0.0, 1.0) + ax.set_ylim(0.0, 1.0) + ax.set_aspect("equal", adjustable="box") + ax.grid(True, alpha=0.25) + ax.tick_params(labelsize=7) + + xmin, ymin, xmax, ymax = _OCCLUDER + ax.add_patch(Rectangle((xmin, ymin), xmax - xmin, ymax - ymin, color="0.2", alpha=0.18)) + + camera = rec.get("camera", np.array([0.16, 0.50])) + ax.plot(*camera, marker="s", color="tab:blue", markersize=9) + + picked = rec.get("picked", False) + if not picked: + ax.add_patch(Circle(_OBJ_POS, _OBJ_RADIUS, color="tab:red", alpha=0.85)) + + last_detection = rec.get("last_detection") + if last_detection is not None and not picked: + ax.plot(*last_detection, marker="x", markersize=9, color="tab:orange") + + belief_mean = rec.get("belief_mean") + if belief_mean is not None: + ax.add_patch( + Circle( + belief_mean, + rec.get("belief_radius", 0.08), + fill=False, + linestyle="--", + color="tab:green", + linewidth=2, + ) + ) + + info = rec.get("info", {}) + if "pick_position" in info: + ax.plot(*info["pick_position"], marker="+", markersize=14, color="black") + + status = f"attempts={rec.get('attempts', 0)}" + if picked: + status += " PICKED ✓" + elif isinstance(info.get("failure"), Failure): + status += f" {info['failure'].kind}" + ax.text( + 0.02, + 0.97, + status, + transform=ax.transAxes, + va="top", + fontsize=9, + bbox=dict(boxstyle="round", facecolor="white", edgecolor="0.7", alpha=0.85), + ) + + +def search_seeds(max_steps: int, limit: int = 60) -> None: + pick_module = load_example("examples/manipulation/01_pick_and_retry.py") + print("seed naive(picked,attempts) smart(picked,attempts)") + for seed in range(limit): + naive = run_episode(lambda: NaiveAgent(), seed=seed, max_steps=max_steps) + smart = run_episode(lambda: pick_module.PickAndRetryAgent(), seed=seed, max_steps=max_steps) + np_ = episode_outcome(naive) + sp_ = episode_outcome(smart) + flag = " <== clean story" if (not np_[0] and sp_[0]) else "" + print(f"{seed:>4} {np_} {sp_}{flag}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--seed", type=int, default=3) + parser.add_argument("--max-steps", type=int, default=10) + parser.add_argument("--fps", type=int, default=2) + parser.add_argument("--search", action="store_true", help="scan seeds and exit") + args = parser.parse_args() + + if args.search: + search_seeds(args.max_steps) + return + + frames = build_frames(args.seed, args.max_steps) + OUT_DIR.mkdir(parents=True, exist_ok=True) + out = OUT_DIR / GIF_NAME + imageio.mimsave(out, frames, duration=1.0 / args.fps, loop=0) + print(f"wrote {out} ({len(frames)} frames)") + + +if __name__ == "__main__": + main()