From 8cf883734247686395b96b37af1b0cbcaf668727 Mon Sep 17 00:00:00 2001 From: Amber Li Date: Thu, 30 Jul 2026 17:13:07 -0400 Subject: [PATCH 1/4] domino_real: fold perceived roll modulo pi (a domino is a box) Every domino in a captured scene was standing, but two of the four came back with roll = pi and read as Toppled before anything moved. A task whose goal is Toppled(target) was then satisfied in its own initial state: the planner returned a length-0 plan and the run reported SOLVED. A domino is a box, so turning it 180 degrees about its own width axis leaves it exactly where it was. Both orientations describe the same physical domino and a marker-based pose estimate returns either one arbitrarily -- in a single capture, some dominoes come back at roll 0 and others at roll +-pi. Roll is only meaningful modulo pi, so it is now folded into [-pi/2, pi/2): standing (0 or +-pi) folds to ~0, and knocked over (+-pi/2) keeps the magnitude that Toppled and Upright are defined on. Yaw is untouched, so the push direction is unaffected. This is a regression from the toppled-domino support in plan PR 6. Before it, both conversions hard-coded roll = 0, so a flipped estimate could not surface. The offline check against scenes 0000/0001 missed it because every domino in those two captures happened to come back near roll 0. The fold lives in the domino env rather than in domino_env_euler, so the geometry helper stays a faithful decomposition and the "a domino is a box" fact sits with the domain that knows it. Tests: the invariant that no domino starts toppled when built from a capture whose records are flipped, the same for a mid-episode observation, and the fold itself over eight angles. Verified against the real capture that exposed this -- all four dominoes now read 0.00 deg and the goal is no longer true at init. --- predicators/envs/pybullet_domino_real.py | 26 +++++++- tests/envs/test_pybullet_domino_real.py | 79 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/predicators/envs/pybullet_domino_real.py b/predicators/envs/pybullet_domino_real.py index 023a8c623..2e1f73775 100644 --- a/predicators/envs/pybullet_domino_real.py +++ b/predicators/envs/pybullet_domino_real.py @@ -43,6 +43,28 @@ def _base_pose(xyz: Sequence[float], quat_xyzw: Sequence[float]) -> Pose6D: return Pose6D((x, y, z), (qx, qy, qz, qw)) +def _canonical_roll(roll: float) -> float: + """Fold a perceived roll into ``[-pi/2, pi/2)``. + + A domino is a box, so turning it 180 degrees about its own width + axis leaves it exactly where it was. Both orientations are equally + correct descriptions of the same physical domino, and a marker-based + pose estimate returns one or the other arbitrarily -- in a single + capture, some dominoes come back at roll 0 and others at roll +-pi. + + Roll is therefore only meaningful modulo pi, and folding it is not + cosmetic: ``Toppled`` is defined as ``|roll| >= 10 degrees``, so an + unfolded ``roll = pi`` makes an upright domino read as knocked over + before anything has moved. A task whose goal is ``Toppled(target)`` + is then already satisfied in its own initial state, and the planner + returns an empty plan and reports success. + + Standing (0 or +-pi) folds to ~0. Knocked over (+-pi/2) keeps its + magnitude, which is all ``Toppled`` and ``Upright`` read. + """ + return (roll + math.pi / 2) % math.pi - math.pi / 2 + + @dataclass(frozen=True) class _PerceivedDomino: """One domino as perceived, normalized from either source. @@ -288,6 +310,8 @@ def _env_angles(self, world: Pose6D, table. Anything else (propped diagonally on a neighbour, say) loses its pitch when the state is written into PyBullet, so say so rather than silently flattening it. + + The roll is folded modulo pi; see :func:`_canonical_roll`. """ roll, pitch, yaw = domino_env_euler(world) if abs(pitch) >= DominoComponent.domino_roll_threshold: @@ -295,7 +319,7 @@ def _env_angles(self, world: Pose6D, "pybullet_domino_real: domino %s is pitched %.1f deg, which " "the (yaw, roll) domino state cannot represent; dropping the " "pitch", capture_id, math.degrees(pitch)) - return roll, yaw + return _canonical_roll(roll), yaw @staticmethod def _canonical_start_yaw( diff --git a/tests/envs/test_pybullet_domino_real.py b/tests/envs/test_pybullet_domino_real.py index d88325140..b2548b9ae 100644 --- a/tests/envs/test_pybullet_domino_real.py +++ b/tests/envs/test_pybullet_domino_real.py @@ -29,7 +29,8 @@ from predicators import utils from predicators.envs.pybullet_domino.real_geometry import _REAL_TO_ENV_BODY, \ Pose6D, domino_env_euler, domino_upright_yaw, pose_base_to_world -from predicators.envs.pybullet_domino_real import PyBulletDominoRealEnv +from predicators.envs.pybullet_domino_real import PyBulletDominoRealEnv, \ + _canonical_roll from predicators.pybullet_helpers.real_robot_bridge import \ gripper_joint_layout_from_robot from predicators.structs import GroundAtom @@ -616,3 +617,79 @@ def test_unrepresentable_pitch_is_reported(env, caplog): env.state_from_observation(obs, task.init) assert "pitched" in caplog.text + + +# -- the 180-degree box symmetry --------------------------------------------- +# A domino is a box: turning it 180 degrees about its own width axis leaves it +# exactly where it was, and a marker-based pose estimate returns either +# representative arbitrarily. Roll is therefore only meaningful modulo pi. + + +@pytest.mark.parametrize("raw_deg, folded_deg", [ + (0.0, 0.0), + (180.0, 0.0), + (-180.0, 0.0), + (5.0, 5.0), + (-5.0, -5.0), + (90.0, -90.0), + (-90.0, -90.0), + (175.0, -5.0), +]) +def test_canonical_roll_folds_modulo_pi(raw_deg, folded_deg): + """Standing folds to ~0; knocked over keeps its magnitude. + + Magnitude is what matters: ``Toppled`` and ``Upright`` are both + defined on ``|roll|``, so a fold that flips the sign at +-90 degrees + is free, while folding 180 -> 0 is the whole point. + """ + got = _canonical_roll(np.deg2rad(raw_deg)) + assert np.rad2deg(got) == pytest.approx(folded_deg, abs=1e-9) + + +def test_no_domino_starts_toppled_when_perception_flips_it(tmp_path): + """A capture where some dominoes come back 180-degree-flipped must still + build a task whose dominoes are all standing. + + This is the regression. Perception legitimately reports a standing + domino as ``roll = pi``; unfolded, ``Toppled`` (``|roll| >= 10 deg``) + then holds for it in the task's own initial state, so a + ``Toppled(target)`` goal is satisfied before anything moves and the + planner returns an empty plan and reports success. + """ + flipped = _base_quat(roll=np.pi) + scene = _write_scene(tmp_path, [ + _record(_START_ID, (0.0, 0.0)), + _record(11, (0.1, 0.05), quat=flipped), + _record(_TARGET_ID, (0.2, 0.0), quat=flipped), + ], + name="flipped.json") + env = _make_env(scene) + comp = env._domino_component # pylint: disable=protected-access + task = env._build_task_from_scene() # pylint: disable=protected-access + + for slot in range(3): + dom = comp.dominos[slot] + # pylint: disable=protected-access + assert comp._Upright_holds(task.init, [dom]), \ + f"{dom.name} did not start upright" + assert not comp._Toppled_holds(task.init, [dom]), \ + f"{dom.name} started toppled" + + # And the goal is therefore not already satisfied by the initial state. + assert not task.goal.issubset(utils.abstract(task.init, env.predicates)) + + +def test_a_flipped_observation_is_still_upright(env): + """The same fold applies mid-episode, not just at task construction.""" + task = env._build_task_from_scene() # pylint: disable=protected-access + comp = env._domino_component # pylint: disable=protected-access + target = comp.dominos[3] + + obs = _StubDominoObservation( + [_StubDominoPose(_TARGET_ID, (0.2, 0.0, 0.03), _base_quat(np.pi))]) + state = env.state_from_observation(obs, task.init) + + assert state.get(target, "roll") == pytest.approx(0.0, abs=1e-9) + # pylint: disable=protected-access + assert comp._Upright_holds(state, [target]) + assert not comp._Toppled_holds(state, [target]) From c8e19b3a4b55d39c0b60d22c8a0d93b451363ca7 Mon Sep 17 00:00:00 2001 From: Amber Li Date: Fri, 31 Jul 2026 08:50:57 -0400 Subject: [PATCH 2/4] configs: a Stage 1 launcher for oracle on the real domino scene There was no command that ran the oracle arm on this env: oracle.yaml un-skips fan, and launch_simp.py takes only -c, so running Stage 1 meant editing a shared config that fan runs also use. This is a thin launcher of the kind the repo already uses -- it only un-skips the env and arm it runs. It also documents the trap that sent Stage 1 off the rails: a bare `python predicators/main.py --env pybullet_domino_real --approach oracle` inherits none of the env's flags from envs/all.yaml, and this env does not work on the settings.py defaults. Most sharply, domino_use_domino_blocks_as_target defaults False, which sizes the domino component with the target held as a separate object -- so a 4-domino scene allocates 3 slots and task construction dies with "perceived 4 dominoes but only 3 slots". The scene path is set here rather than in settings.py because envs/all.yaml already sets it, and a config value beats the settings.py default -- so editing settings.py has no effect on a launcher run. Lands with the roll fold so that one branch both runs Stage 1 and answers it truthfully. --- .../predicatorv3/oracle_domino_real.yaml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/configs/predicatorv3/oracle_domino_real.yaml diff --git a/scripts/configs/predicatorv3/oracle_domino_real.yaml b/scripts/configs/predicatorv3/oracle_domino_real.yaml new file mode 100644 index 000000000..9aa270ad7 --- /dev/null +++ b/scripts/configs/predicatorv3/oracle_domino_real.yaml @@ -0,0 +1,41 @@ +# Thin launcher: the ORACLE arm on the real-world domino scene, in pure +# simulation. This is Stage 1 of docs/real_robot_bringup.md -- it answers +# "is this captured scene solvable at all?", which gates every stage that +# touches hardware. +# Usage: python scripts/local/launch_simp.py -c predicatorv3/oracle_domino_real.yaml +# +# Nothing real: real_robot_execute stays False (envs/all.yaml), so no executor +# is attached and no arm moves. +# +# Why a launcher rather than `python predicators/main.py --env +# pybullet_domino_real --approach oracle`: that bare command inherits none of +# the env's flags from envs/all.yaml, and this env does not work on the +# settings.py defaults. Most sharply, domino_use_domino_blocks_as_target +# defaults False, which sizes the domino component with the target held as a +# separate object -- so a 4-domino scene allocates 3 slots and task +# construction fails with "perceived 4 dominoes but only 3 slots". +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_real: + SKIP: False + FLAGS: + # Point this at the scene you captured in Stage 0. Note this must be set + # HERE, not in settings.py: envs/all.yaml already sets it, and a config + # value beats the settings.py default, so editing settings.py has no + # effect on a launcher run. + domino_real_scene: "/home/amberli/babyrobot/BabyRobotPredicator/scenes/domino_straight.json" + # Matches how oracle runs the other domino envs: the push target is + # inferred from state rather than named by the caller. The agent configs + # rely on the codebase default (False) so an LLM can name the trigger + # domino explicitly. + domino_restricted_push: True +APPROACHES: + oracle: + SKIP: False +# One seed: this is a solvability check on one captured scene, not a +# statistical comparison. +NUM_SEEDS: 1 From c600fab0afc1fd0c3852973a4fe0a3e324079164 Mon Sep 17 00:00:00 2001 From: Amber Li Date: Fri, 31 Jul 2026 11:30:56 -0400 Subject: [PATCH 3/4] replay_plan: the oracle approach has no NSRTs for this env replay_plan has been dead for pybullet_domino_real since the CogMan rewiring landed: NotImplementedError: Ground-truth NSRTs not implemented for env: pybullet_domino_real It built its CogMan with create_approach("oracle", ...) on the reasoning that the approach is never consulted for control -- an override policy is always set, so _reset_policy takes that branch and never calls solve. That part is right, but OracleApproach builds ground-truth NSRTs in its *constructor*, and this env has none: it is planned over processes, which is why the process-planning oracle's _get_current_nsrts returns an empty set. So the replay died before rendering a frame. Uses random_options instead: a plain BaseApproach that needs nothing but the option set, which is all a fixed-plan replay requires. Verified by rendering a single Push on a real captured scene -- `steps=59 goal_reached=True`, MP4 written. --- scripts/domino_debug/replay_plan.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/domino_debug/replay_plan.py b/scripts/domino_debug/replay_plan.py index e088e94b2..ec3c71d8d 100644 --- a/scripts/domino_debug/replay_plan.py +++ b/scripts/domino_debug/replay_plan.py @@ -139,11 +139,16 @@ def main() -> None: # driven by an override policy, exactly as the online-learning path does # (main.py sets the override, then resets). With an override in place # CogMan never asks the approach to solve, so the plan being replayed is - # the plan that executes. That is also why the approach and monitor below - # are the cheap ones: neither is consulted for control, and "oracle" plus - # "trivial" avoid dragging an LLM into a debug replay. + # the plan that executes. + # + # The approach is therefore never consulted for control, and only has to + # *construct*. "random_options" is a plain BaseApproach that needs nothing + # but the option set. Not "oracle": that one builds ground-truth NSRTs in + # its constructor, and this env has none -- it is planned over processes, + # so get_gt_nsrts raises NotImplementedError for pybullet_domino_real and + # the replay dies before it renders a frame. cogman = CogMan( - create_approach("oracle", env.predicates, + create_approach("random_options", env.predicates, get_gt_options(env.get_name()), env.types, env.action_space, [task]), create_perceiver(CFG.perceiver), create_execution_monitor("trivial")) From dbb4412e688e49ee7778b892797b861d47cfa9c1 Mon Sep 17 00:00:00 2001 From: Amber Li Date: Fri, 31 Jul 2026 15:01:05 -0400 Subject: [PATCH 4/4] Drop the oracle domino_real launcher from this PR Narrows this PR to the roll fold alone. The launcher is preserved verbatim on the oracle-domino-real-launcher branch. --- .../predicatorv3/oracle_domino_real.yaml | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 scripts/configs/predicatorv3/oracle_domino_real.yaml diff --git a/scripts/configs/predicatorv3/oracle_domino_real.yaml b/scripts/configs/predicatorv3/oracle_domino_real.yaml deleted file mode 100644 index 9aa270ad7..000000000 --- a/scripts/configs/predicatorv3/oracle_domino_real.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# Thin launcher: the ORACLE arm on the real-world domino scene, in pure -# simulation. This is Stage 1 of docs/real_robot_bringup.md -- it answers -# "is this captured scene solvable at all?", which gates every stage that -# touches hardware. -# Usage: python scripts/local/launch_simp.py -c predicatorv3/oracle_domino_real.yaml -# -# Nothing real: real_robot_execute stays False (envs/all.yaml), so no executor -# is attached and no arm moves. -# -# Why a launcher rather than `python predicators/main.py --env -# pybullet_domino_real --approach oracle`: that bare command inherits none of -# the env's flags from envs/all.yaml, and this env does not work on the -# settings.py defaults. Most sharply, domino_use_domino_blocks_as_target -# defaults False, which sizes the domino component with the target held as a -# separate object -- so a 4-domino scene allocates 3 slots and task -# construction fails with "perceived 4 dominoes but only 3 slots". ---- -includes: - - common.yaml - - envs/all.yaml - - approaches/all.yaml -ENVS: - domino_real: - SKIP: False - FLAGS: - # Point this at the scene you captured in Stage 0. Note this must be set - # HERE, not in settings.py: envs/all.yaml already sets it, and a config - # value beats the settings.py default, so editing settings.py has no - # effect on a launcher run. - domino_real_scene: "/home/amberli/babyrobot/BabyRobotPredicator/scenes/domino_straight.json" - # Matches how oracle runs the other domino envs: the push target is - # inferred from state rather than named by the caller. The agent configs - # rely on the codebase default (False) so an LLM can name the trigger - # domino explicitly. - domino_restricted_push: True -APPROACHES: - oracle: - SKIP: False -# One seed: this is a solvability check on one captured scene, not a -# statistical comparison. -NUM_SEEDS: 1