Skip to content

[WIP] Two-Camera Fused Pose Estimation - #82 - #1749

Open
emilybunna wants to merge 121 commits into
Learning-and-Intelligent-Systems:masterfrom
BasisResearch:eb-camera-sync
Open

[WIP] Two-Camera Fused Pose Estimation - #82#1749
emilybunna wants to merge 121 commits into
Learning-and-Intelligent-Systems:masterfrom
BasisResearch:eb-camera-sync

Conversation

@emilybunna

Copy link
Copy Markdown

This PR is coupled with https://github.com/BasisResearch/BabyRobotPredicator/pull/82.

We use information from two cameras to do pose estimation, in order to avoid occluded or blocked views.

This PR is a WIP, blocked by:

  1. Testing with calibrated cameras.
  2. Integration with our current full workflow.
  3. Instead of averaging position, let's default to one camera and use the other when necessary.

yichao-liang and others added 30 commits March 24, 2026 21:00
- Add CausalProcess, DerivedPredicate, delay distributions to structs.py
- Add ~286 new settings for PyBullet envs, process planning, agent SDK
- Extend utils.py with process grounding, abstract states, parallel helpers
- Add colorlog, torchvision dependencies; update CI config
- Extract common robot/finger methods to base PyBulletEnv
- Add objects.py module for create_object/update_object helpers
- Enhance controllers with move_to_pose tolerance, press, nudge
- Add BiRRT path subsampling, collision diagnostics, IK validation
- Add MobileFetch robot support
Add 161 URDF models, meshes, and textures for boil, circuit, coffee,
domino, fan, float, grow, laser, and switch environments. Includes
partnet_mobility models (bucket, fan, faucet, switch) and custom URDFs.
Implements generic pick, place, pour, push, move_to, and wait skills
with phase-based trajectories, BiRRT motion planning, and canonical
continuous parameters. Skills are reusable across all PyBullet envs.
New environments: coffee, grow, boil, domino, fan, balance, float,
laser, ants, circuit, barrier, magic_bin, switch. Enhanced: blocks,
cover. Each includes options, NSRTs, and process ground truth models
with skill factory integration. Domino uses component-based architecture.
- planning_with_processes.py: FF/LM-cut heuristics, derived predicates,
  process scheduling, abstract process policies
- Cluster-and-search process learner with LLM-guided condition selection
- ELBO optimization with learnable delay distributions
- Process planning approaches: oracle, param learning, predicate invention
- Enhanced predicate score functions and grammar search for processes
- Restructure main.py into dedicated setup/demo/test functions
- Add timestamped run directories for logs
- Enhance cogman with process-aware action execution
- Add option_model use_gui override and process model support
- Extend explorers with plan enumeration and fallback selection
- Add pretrained model interface OpenRouter compatibility
- agent_sdk/: MCP tools, local/Docker sandboxes, session management,
  proposal parsing, markdown log formatting
- Agent approaches: planner, option learning, abstraction learning,
  closed loop planning with scratchpad and visualization support
- Docker infrastructure for sandboxed agent execution
…aches

- Human interaction approach with motion planning and scripted policies
- Human low-level control approach with keyboard input
- VLM online predicate invention with transition modeling
- Classification approaches (VLM, DINO similarity)
- Mara-robosim adapter wrappers for 15 PyBullet environments
- LLM/VLM prompt templates for predicate invention
- Developer docs, random action GIFs, env guide
- Experiment configs (ExoPredicator, predicator_v3, mara_bench)
- Launch scripts, plotting utilities, scripted option policies
- Tests for skill factories, agent SDK tools, controllers
Improve motion planning and skill factories
Add composable config system with includes support
  ## CI Infrastructure
  - Parallelize unit tests into 8 shards using pytest-split
  - Add pip caching and coverage merge job
  - Add .test_durations file for balanced sharding

  ## Dependency Fixes
  - Add missing deps: psutil, claude-agent-sdk, nest_asyncio, mara_robosim
  - Remove wandb and opencv-python

  ## Code Cleanup
  - Rename RawState to VLMState
  - Fix type annotations for PyBulletState image properties
  - Eliminate type: ignore[unreachable] comments
  - Extract grid component, remove old domino envs
  - Remove unused process_learning module
  - Clean up cogman.py

  ## Lint & Type Fixes
  - Fix mypy errors for LBFGS step() with newer torch stubs
  - Fix pylint issues across domino envs, ground truth models, tests
  - Autoformat fixes for yapf/isort

  ## Test Fixes
  - Default use_gui=False to prevent PyBullet hanging in headless CI
  - Make OpenAI key optional for mock LLM tests
  - Fix dataset loading tests for pytest-split compatibility
  - Ensure eval_trajectories/ and results/ dirs exist in _run_pipeline
  - Fix active sampler explorer test flakiness
  - Mark domino pick test as xfail for headless CI
  - Retry motion planning test with multiple seeds
  - Fix planning determinism and external oracle approach tests
…on (#19)

* Parameterize Wait option with target atoms for noise-robust termination

Wait previously terminated on any atom change, making it sensitive to
incidental physics noise. Now Wait can be parameterized with specific
target atoms (positive or negative) that must be satisfied before
termination. Falls back to any-atom-change when no targets are specified.

- Add check_wait_target_atoms, parse_wait_target_annotations,
  strip_wait_annotations, inject_wait_targets_for_option to utils.py
- Update option_model.py termination + memory propagation through re-grounding
- Update LLM prompts in agent_planner, agent_bilevel, agent_option_learning
  approaches to document -> {atoms, NOT atoms} annotation syntax
- Parse and inject Wait targets in agent_planner and agent_bilevel approaches
- Inject Wait targets from atoms_sequence in process planning paths
- Add tests for target atom termination, including noisy-atom-ignored test

* Fix formatting and exclude logs dir from mypy

* Fix pylint unused-argument warning in _inject_wait_targets
* Refactor _validate_plan_forward to use option model directly

Delegate option execution to option_model.get_next_state_and_num_actions
instead of duplicating its termination logic (stuck detection, Wait
atom-change checks) and directly accessing its simulator.

* Unify backtracking refinement search into shared run_backtracking_refinement

Extract the duplicated backtracking loop from run_low_level_search (SeSamE)
and _refine_sketch (agent bilevel) into a single run_backtracking_refinement
function in planning.py. Both callers now delegate to it with their own
sample_fn and validate_fn callbacks, eliminating ~80 lines of duplicated
loop/backtracking logic.

* Simplify _validate_plan_forward to use run_backtracking_refinement

Replace 60 lines of manual option-model execution with a call to
run_backtracking_refinement using max_tries=[1] and a sample_fn that
returns the pre-grounded options. Remove unused Any import.
* Add gymnasium-API benchmark wrapper and retire mara_adapter shim

Wrap predicators' native PyBullet envs in a standard gymnasium.Env so
the suite can be consumed as a benchmark independent of the planning
framework. Drop the predicators -> mara_robosim adapter layer, which
only existed to bridge two near-identical struct types.

New:
  - predicators/envs/gymnasium_wrapper.py: PredicatorsBenchmarkEnv plus
    register_all_environments(), make(), get_all_env_ids(). Registers
    15 envs under mara/*-v0, with a cfg_overrides kwarg for per-make
    CFG overrides.
  - notebooks/getting_started.ipynb: interactive walkthrough.
  - scripts/benchmark_getting_started.py: smoke test that mirrors the
    notebook and resets every registered env.
  - tests/envs/test_gymnasium_wrapper.py: 12 unit tests.

Removed:
  - predicators/envs/mara_adapter.py (790 lines).
  - mara_robosim git dependency from setup.py.
  - _normalize_env_name_for_gt helper in ground_truth_models, inlined
    env_name at all 7 callsites.

Docs:
  - predicators/envs/README.md expanded into the benchmark README
    (install, quick-start, env table, standalone API, walkthroughs).
  - Top-level README points at the benchmark README.

* Rebrand the gym wrapper as MARA RoboSim and audit env status

User-facing rename: the Gymnasium wrapper now exports `MARARoboSimEnv`
(was `PredicatorsBenchmarkEnv`), the import idiom is
`from predicators.envs import gymnasium_wrapper as mara_robosim`
(was `as benchmark`), and the smoke script + tests are renamed to
`mara_robosim_getting_started.py` and `test_mara_robosim.py`. The
underlying file stays `predicators/envs/gymnasium_wrapper.py` so the
filename still describes the implementation.

Env table: `predicators/envs/README.md` gains three status columns
(Tasks / Skills / Demos) populated from a fresh oracle-planning audit:
- 4 envs solve oracle test tasks end-to-end (Ants, Blocks, Circuit,
  Cover).
- 8 envs have non-empty option factories but oracle planning currently
  fails on default config (Balance, Boil, Coffee, Domino, Fan, Float,
  Grow, Laser).
- 3 envs ship empty option sets (Barrier, MagicBin, Switch).

Also tweak the top-level README section heading to "MARA RoboSim".

* Correct env status columns in MARA RoboSim README

Update the Tasks/Skills/Demos columns based on a manual audit:
several envs were previously marked ❌ for Demos but actually solve
oracle tasks under different configs (Boil, Domino, Fan, Float, Grow);
several others have a fixed task generator rather than randomized
init/goal sampling (Ants, Barrier, Circuit, Float, Laser, MagicBin,
Switch).

* Add walkthroughs for getting started with MARA RoboSim environments

* Move Walkthroughs section above the env table

Promotes the notebook + smoke-script links so readers see them
immediately after Quick Start, before scrolling through the 15-row
status table.
…DME (#27)

The two most recent papers that use this codebase — VisualPredicator
on neuro-symbolic predicate invention for robot planning and
ExoPredicator on learning abstract models of dynamic worlds with
exogenous processes.
)

RoboDisco (Robot Model Discovery Benchmark) is a clearer name for the
PyBullet manipulation suite, matching its positioning as an embodied
world-model and causal-discovery benchmark.

User-facing rename:
- `MARARoboSimEnv` class → `RoboDiscoEnv`
- Import idiom → `from predicators.envs import gymnasium_wrapper as robodisco`
- Gymnasium env IDs → `robodisco/Blocks-v0` (and the other 14 envs)
- Entry point string → `predicators.envs.gymnasium_wrapper:RoboDiscoEnv`
- Smoke script → `scripts/robodisco_getting_started.py` (+ output dir)
- Test file → `tests/envs/test_robodisco.py`
- .gitignore, top-level README, predicators/envs/README, and notebook
  updated to match.

The env table in `predicators/envs/README.md` also gains a link to the
project page at https://yichao-liang.github.io/robodisco-site/.

The module file stays `predicators/envs/gymnasium_wrapper.py` — the
filename describes the implementation; the brand is applied at the
class, env-ID, and import-alias layer.
* stop tracking CLAUDE.md

* use pybullet-arm64 fork for arm64 compatibility

---------

Co-authored-by: Yichao Liang <ycliang@Yichaos-MacBook-Air.local>
* Refactor _validate_plan_forward to use option model directly

Delegate option execution to option_model.get_next_state_and_num_actions
instead of duplicating its termination logic (stuck detection, Wait
atom-change checks) and directly accessing its simulator.

* Unify backtracking refinement search into shared run_backtracking_refinement

Extract the duplicated backtracking loop from run_low_level_search (SeSamE)
and _refine_sketch (agent bilevel) into a single run_backtracking_refinement
function in planning.py. Both callers now delegate to it with their own
sample_fn and validate_fn callbacks, eliminating ~80 lines of duplicated
loop/backtracking logic.

* Simplify _validate_plan_forward to use run_backtracking_refinement

Replace 60 lines of manual option-model execution with a call to
run_backtracking_refinement using max_tries=[1] and a sample_fn that
returns the pre-grounded options. Remove unused Any import.

* Refactor _current_observation/_current_state usage in pybullet_env

Move the _current_observation assignment into _reset_state so callers
don't need to remember the two-step pattern.  Clarify the relationship
between _current_observation (backing field) and _current_state (typed
read accessor) in docstrings and comments.

* Add CFG option to load plan sketch from file instead of LLM

Adds agent_bilevel_plan_sketch_file setting that, when set to a file
path, loads the plan sketch directly from that file, bypassing the
foundation model query. Includes test data files and a unit test.

* Remove redundant conditions from Place action in boil_plan_sketch

* Scale target joint value based on switch_joint_scale in PyBulletBoilEnv

* Refactor _terminal in option model to deduplicate wait-termination logic

Extract repeated wait-termination check into _check_wait_termination helper
and unify the three _terminal branches into a single definition with
config checks inside the function body.

* Refactor terminal state logging in _OracleOptionModel to simplify condition checks

* Format docstring in get_observation method for improved readability

* Refactor PyBulletEnv for readability and better naming

- Remove dead/commented-out code and stale self-question comments
- Add _VIRTUAL_OBJECT_TYPES constant to replace hardcoded type-name
  skip lists in _set_state and _get_state
- Move env-specific _get_robot_state_dict branches to subclass overrides
  in pybullet_cover and pybullet_blocks
- Extract _get_camera_matrices helper to deduplicate render methods
- Extract _get_object_state_dict from _get_state for per-object logic
- Move create_pybullet_block/sphere to pybullet_helpers/objects.py
- Merge _create_task_specific_objects into _set_domain_specific_state
- Rename: _reset_state -> _set_state,
  _reset_custom_env_state -> _set_domain_specific_state,
  _extract_feature -> _get_domain_specific_feature
- Add docstrings explaining where each method is called from

* Regroup PyBulletEnv methods by responsibility and update docstring

Reorganize methods into labeled sections (Setup, Public API, Core Loop,
State Write/Read, Grasp Management, Action Helpers, Rendering, Utilities)
so related functions are adjacent. Update module docstring to document
the main public API and state synchronization methods.

* Refactor PyBulletEnv: extract _domain_specific_step from step()

Add _step_base() and _domain_specific_step() to PyBulletEnv base class.
step() now calls _step_base (robot control, physics, grasp) then
_domain_specific_step (water filling, heating, etc.), gated by
_skip_domain_specific_dynamics flag for kinematics-only mode.

Migrate all 15 domain envs to override _domain_specific_step() instead
of step(). Envs with pre-step logic (coffee, switch, blocks, cover)
still override step() for the pre-step part only.

* Update PyBulletEnv module docstring for step() refactoring

Document the step_base → domain_specific_step → get_observation flow,
_skip_domain_specific_dynamics flag, and _domain_specific_step as an
optional override.

* Add skip_process_dynamics constructor param to PyBulletEnv

Replace direct access to private _skip_domain_specific_dynamics
attribute with a public constructor parameter, so callers declare
kinematics-only mode at creation time instead of mutating internal
state after construction.

* Extract run_query_sync helper to remove duplicated async-to-sync bridging

Both AgentSessionMixin and AgentExplorer had near-identical wrappers that
ran session.query() synchronously via nest_asyncio or asyncio.run. Move
that logic into a module-level run_query_sync helper in session_manager
and have both callers delegate to it.

* Refactor main function: extract and modularize setup logic for clarity and maintainability

* Rename agent explorer to agent_plan for clearer naming

Distinguishes the grounded-plan explorer from upcoming bilevel variants.
AgentExplorer -> AgentPlanExplorer, get_name() 'agent' -> 'agent_plan',
file moved to agent_plan_explorer.py, and all callers / docstrings /
YAML config examples updated accordingly.

* Move AgentSessionMixin into agent_sdk package

The mixin is pure agent-session plumbing (session creation, lifecycle,
explorer factory) and has no approach-specific logic, so it belongs
next to session_manager.py, tools.py, and the sandbox managers rather
than in approaches/.

* Add AgentBilevelExplorer for sim-learning experiments

The explorer asks a Claude agent for a plan sketch, refines it against
the approach's current (possibly learned) option model, and rolls the
refined plan out in the real env. When the mental model disagrees with
reality — e.g. the sketch expects JugFilled after a Wait but the mental
model's process dynamics can't produce it — the explorer truncates the
plan at the deepest unsatisfiable subgoal (inclusive) so the real-env
rollout ends exactly where the disagreement occurs, maximising signal
per experiment.

Key pieces:

- predicators/agent_sdk/bilevel_sketch.py: extracted the sketch build
  / parse / refine helpers from AgentBilevelApproach as module-level
  functions so both the approach (solve path) and the new explorer
  (exploration path) can share them. refine_sketch gains
  truncate_on_subgoal_fail: the on_step_fail callback snapshots the
  deepest subgoal failure seen during backtracking, and on exhaustion
  the captured prefix is returned as the experiment plan.

- predicators/explorers/agent_bilevel_explorer.py: new explorer.
  Reads option_model from tool_context (synced by the approach),
  builds the sketch prompt via bilevel_sketch, runs refine_sketch with
  check_subgoals=True, check_final_goal=False, truncate_on_subgoal_fail
  =True, wraps the result in an option_plan_to_policy that converts
  OptionExecutionFailure into RequestActPolicyFailure so the episode
  cleanly terminates at the point of real-env divergence. Stashes the
  sketch subgoals/options on ToolContext for downstream diffing by
  the learning approach.

- predicators/approaches/agent_bilevel_approach.py: shim methods over
  bilevel_sketch; behaviour unchanged.

- predicators/approaches/agent_planner_approach.py: _create_explorer
  dispatches both "agent_plan" and "agent_bilevel" through the agent
  factory path and forwards CFG.explorer as the name.

- predicators/explorers/__init__.py: factory branch merged for the
  two agent-session-backed explorers.

- predicators/agent_sdk/tools.py: ToolContext gains
  last_sketch_subgoals / last_sketch_options fields, populated by the
  explorer and marked TODO for the learning approach to consume.

- tests/explorers/test_agent_bilevel_explorer.py: happy-path, fallback,
  wait-memory-injection, and deepest-subgoal-failure truncation tests.

* Add explorer-specific sample budget and experiment-plan logging

- New setting agent_bilevel_explorer_max_samples_per_step (default 50),
  separate from the solve-path budget, so the explorer's backtracking
  cost is independently tunable.
- Log the actual experiment plan (option names, objects, params) after
  refinement so the explorer's output is visible alongside the
  existing sketch/truncation log lines.
- Test config updated to set both budgets explicitly.

* Add sim-learning approach and synthesis tooling

AgentSimLearningApproach extends AgentBilevelApproach to learn process
dynamics online. Each cycle: the agent synthesizes parameterized
process rules via Claude (using run_python / evaluate_simulator /
test_simulator MCP tools), parameters are fitted via emcee MCMC, and
the learned dynamics are composed with a kinematics-only PyBullet
oracle into a combined option model for plan refinement.

Key pieces:
- predicators/approaches/agent_sim_learning_approach.py: the approach.
  Initialises with a kinematics-only option model (so
  AgentBilevelExplorer sees disagreements at process-dynamic subgoals
  like JugFilled/Boiled), and replaces it with the kin+learned model
  after each successful synthesis cycle.
- predicators/agent_sdk/tools.py: create_synthesis_tools() builds the
  three MCP tools the synthesis agent uses; extra_mcp_tools field and
  get_allowed_tool_list(extra_names=) plumbing lets the approach
  inject them into the session.
- predicators/code_sim_learning/: ParamSpec, fit_params (emcee MCMC),
  compute_mse, LearnedSimulator.
- predicators/ground_truth_models/boil/gt_simulator.py: ground-truth
  process-dynamics simulator for the boil environment.
- tests/: approach and param-fitting tests.

* Update experiment configs for sim-learning

- agents.yaml: comment out agent_bilevel preset, add agent_sim_learning
  with explorer=agent_bilevel and skip_test_until_last_ite_or_early_stopping.
- common.yaml: disable failure/test video recording, set
  num_online_learning_cycles=1 for faster iteration.

* Refactor sim-learning: extract primitives, add GT simulator factory

Simulation primitives (code_sim_learning/utils.py):
- apply_rules(state, rules, params) → ProcessUpdate
- merge_updates(base_state, updates, process_features) → State
- simulate_step(state, action, base_env, rules, params, features) → State
These replace _build_fitted_step_fn, merge_process_updates,
_sim_fn_from_rules, and the body of _build_combined_simulator.

GT simulator factory (ground_truth_models):
- GroundTruthSimulatorFactory ABC + get_gt_simulator(env_name) discovery,
  following the existing get_gt_options / get_gt_nsrts pattern.
- PyBulletBoilGroundTruthSimulatorFactory registered in boil/.
- Replaces the hardcoded _load_oracle_simulator in the approach.

Oracle ablation flags (settings.py):
- agent_sim_learn_oracle_sim_program: load GT rules, skip synthesis.
- agent_sim_learn_oracle_sim_params: use GT param values, skip MCMC.

Also: kin_env → base_env rename throughout, redundant self._types
assignment removed, process_features computed once in __init__.

* Fix formatting, pylint, and mypy issues for CI compliance

- yapf + isort autoformatting applied to all touched files.
- pylint: fix logging-not-lazy in agent_bilevel_explorer, add
  broad-except and reimported disables in agent_sim_learning_approach.
- mypy: fix base/env variable name collision, add type: ignore on
  lambda inference, add return type annotations to GT factory methods.

* Update test setup to use test tasks for boil environment and refine test description

* Refactor combined model in GT simulator

* Fix expected-atoms check to support DerivedPredicates

Use utils.abstract to evaluate expected atoms in low-level search so
that DerivedPredicates — which require a Set[GroundAtom] rather than a
State — are handled correctly alongside regular predicates.

* Skip kinematic reset in PyBullet when only non-kinematic state changed

When sequential simulate calls differ only in process features (as in
the combined kinematic+learned simulator), reapplying joint positions
and tearing down/recreating grasp constraints causes visible arm
jitter. Compare robot poses first and skip the kinematic reset path
when they already match.

* Support offline dataset learning in AgentSimLearningApproach

Factor simulator synthesis into a shared _learn_simulator helper so
that both learn_from_offline_dataset and learn_from_interaction_results
can trigger it on their respective trajectory sources. Also create a
separate headless env for parameter fitting so MCMC's thousands of
_set_state calls don't thrash the GUI env during training.

* Log periodic progress during MCMC parameter fitting

Replace the silent run_mcmc call with a manual sample loop that logs
step count and best log-probability roughly five times per run, and
flushes handlers so the updates appear promptly under buffered
logging.

* Fix mypy and pylint errors for CI compliance

Type-annotate **kwargs on PyBullet env __init__ overrides so mypy
doesn't flag them. Initialize attrs used by _domain_specific_step in
__init__ (pybullet_coffee, pybullet_switch) to silence
defined-outside-init. Type-ignore the emcee import. Fix encoding,
unused, protected-access, and redefined-outer-name warnings in the
sim-learning tests and agent-SDK tooling.

* Apply yapf, isort, and docformatter across the codebase

* Inline approach configs into parent files in predicatorv3

* Preserve robot joint config across PyBullet state save/restore

When a held object's grasp constraint is recreated via _set_state, the
gripper frame must match the original world pose exactly — otherwise
the recorded base_link->object offset is rotated and the object lands
at the wrong world position when the gripper next moves. The State
representation only carries (x, y, z, tilt, wrist), so IK during reset
can pick a different wrist-roll solution and corrupt the constraint.

Thread joint_positions from PyBulletState.simulator_state through
reset_state so we skip IK and restore the exact arm configuration.
Falls back to IK when joints aren't available (plain State).

Also wire wait-termination so refinement and execution can stop Wait
when expected atoms hold instead of running to
max_num_steps_option_rollout: set _abstract_function on the option
model in BilevelPlanningApproach (mirrors AgentPlannerApproach), pass
abstract_function into option_plan_to_policy in
BilevelProcessPlanningApproach, and inject wait_target_atoms per
sample in run_low_level_search.

* Add 'emcee' to the list of install_requires in setup.py

* Force PyBullet FK refresh and skip redundant finger snap

After resetJointState, PyBullet's getLinkState returns a stale link
pose from the previous FK cache, producing 50-500μm drift in the EE
pose readback. Pass computeForwardKinematics=1 so world poses are
recomputed from current joints on every call.

Also skip the explicit finger reset in reset_state when joint_positions
are provided: arm_joints already includes the finger joints, so
set_joints has restored them to their exact continuous values, and the
subsequent loop was overwriting them with the discrete-snapped value
from _fingers_state_to_joint. The finger reset still runs on the IK
path where set_joints leaves fingers untouched.

Together these eliminate the "Could not reconstruct state exactly in
reset" warning noise (24 -> 0 on the boil-oracle run).

* Apply yapf/docformatter to satisfy CI autoformat check

* Configure predicatorv3 demos for offline-only sim-learning runs

common.yaml: switch to one demonstration per task with no online
learning cycle so launch_simp.py exercises only the offline pipeline.

agents.yaml (agent_sim_learning): turn on oracle_sim_program with
oracle_sim_params disabled so synthesis fits parameters but starts
from the ground-truth program structure.

* Add jug orientation handling in PyBulletBoilEnv

* Revert getLinkState to PyBullet default (no computeForwardKinematics flag)

Investigation found no measurable difference in reported Cartesian world
position or orientation whether the flag is True or False, so the
override introduced earlier was not needed.

* Add lo/hi bounds to ParamSpec and skip-MCMC support in fit_params

ParamSpec gains optional lo/hi fields for clamping sampled values.
fit_params now reads num_steps from CFG.code_sim_learning_num_mcmc_steps
by default; passing 0 (or setting the flag to 0) skips emcee entirely
and returns the initial parameter values as the fit result.  burn_in is
also clamped to num_steps-1 to avoid emcee errors on very short runs.
Adds a test covering the skip-MCMC path via CFG.

* Build boil param specs dynamically from CFG with lo/hi bounds

Replace the module-level BOIL_PARAM_SPECS list with _build_param_specs()
so water_fill_speed is derived from CFG.boil_water_fill_speed at call
time rather than import time.  All specs now carry lo=0.0 to prevent
MCMC from sampling physically invalid negative values.  get_param_specs()
is updated to call _build_param_specs() so per-run CFG values are always
reflected.

* Apply lo/hi clamping and configurable noise scale to oracle perturbation

Oracle parameter perturbation now uses the relative scale from
CFG.agent_sim_learn_oracle_sim_param_noise_scale (default 0.2) instead
of a hard-coded 20 % figure, and clamps perturbed values to the lo/hi
bounds declared in each ParamSpec.  Also improves the log message when
MCMC is skipped (num_mcmc_steps == 0) so it is clear no fitting occurred.

* Update installation instructions and add macOS setup script for PyBullet

* Update PyBullet version to 3.2.7 and simplify macOS setup script

* Refactor liquid color update logic and rename related methods for clarity

* Add more debug logging for CogMan and option execution flow

* Handle PyBullet physics server crashes with env recreation and retry

Converts _build_combined_simulator to an instance method so it can
capture self, recreate the base env on pybullet.error, and retry once.
Also catches pybullet.error in the oracle option model alongside
OptionExecutionFailure. Updates agents.yaml config for testing.

* Fix jug orientation handling in PyBulletBoilEnv by restoring rotation logic

* Update installation instructions and dependencies; remove macOS setup script

* Remove mara_robosim dependency from setup.py

* Fix get_gt_simulator to use env_name instead of normalized name

* Add before/after MSE, likelihood, and param-delta logging for parameter fitting

* Use SSE loss and wider walker init so MCMC parameter fitting actually moves

Switch the fitting loss from per-feature MSE to total SSE (drop the /count
in compute_sse) so the Gaussian log-likelihood -0.5*SSE/sigma^2 is in its
correct iid form. The previous MSE form silently rescaled per-observation
noise by sqrt(count), making walker proposals indistinguishable from each
other. Pair this with a wider walker initialization (0.5 * prior_sigma
instead of 1% of init_value) so the swarm covers the prior support and
emcee stretch moves can actually explore.

* Move GT simulator components onto module-globals contract

Unifies oracle and agent-synthesized simulators behind one loader:
read_simulator_components pulls PROCESS_RULES, PARAM_SPECS, and
PROCESS_FEATURES out of any namespace (module dict for oracle,
exec_ns for agent), and get_gt_simulator now returns the triple
including features. merge_updates no longer takes process_features
since the rule producer owns that scope.

* Soften boil parameter-dependent gates with sigmoid weights

Replaces hard ``dist < threshold`` indicators in the boil rules with
sigmoid-smoothed gates of width ``_SOFT_EPS``. Without smoothing, the
LM finite-difference Jacobian is ~zero almost everywhere, and the
Hessian identifiability diagnostic is uninformative; emcee also gets
a non-flat likelihood as a side effect. State-dependent gates
(faucet on/off, jug held) stay hard since they don't enter the
parameter likelihood.

* Add LM warm-start and Hessian identifiability diagnostic

Adds fit_map_lm (Levenberg-Marquardt MAP estimate via SciPy TRF) and
log_hessian_identifiability (eigendecompose J^T J/sigma^2 + prior
precision to flag sloppy parameter directions). Both run as a single
LM pass before MCMC; fit_params now centers walkers on theta_map
when code_sim_learning_warm_start_with_lm is set, and short-circuits
to it directly when num_mcmc_steps == 0. Also adds compute_residuals
(per-feature residual vector LM consumes) and log_sse_breakdown
(per-(type, feature) SSE so we can see which features dominate the
loss). Two CFG flags gate the new behavior:
warm_start_with_lm (default True), log_hessian_identifiability
(default False).

* Infer process-feature scope from base-sim residuals

The agent now declares its own PROCESS_FEATURES alongside
PROCESS_RULES and PARAM_SPECS, and the loss is scoped to that
declaration (instead of every feature on every type). Before
synthesis, the approach runs the base sim on each transition and
flags (type, feat) pairs whose prediction diverges from the
observation on at least min_hits triples; this set is sent to the
agent as a starting hint and used as the eval/test scope until the
agent overrides it. The base-sim prediction is precomputed once
into base_pred_triples so MCMC's inner loop only evaluates the
cheap apply_rules step. create_synthesis_tools now takes the
precomputed triples plus the inferred hint, drops the live base_env,
and reads PROCESS_FEATURES from exec_ns each call (falling back to
the hint when undeclared).

* Skip MCMC and use LM warm-start in boil agent config

LM warm start alone matches the parameter fit for the current
boil oracle program; emcee's MAP-of-walkers cannot improve on it
in the time budgeted for 500 steps and routinely lands at higher
SSE. Setting num_mcmc_steps to 0 and enabling warm_start_with_lm
returns the LM theta_map directly.

* Apply yapf and docformatter formatting

Cleans up line-wrap and docstring drift across the sim-learning
branch so the autoformat CI check is satisfied. Bundles the
formatting-only changes for cogman, pybullet_boil, and utils that
earlier branch commits left behind, plus minor wraps across the
new sim-learning code.

* Silence mypy on PyBullet client-id attribute access

``BaseEnv`` doesn't declare ``_physics_client_id`` (only PyBullet
subclasses do), and ``_recreate_base_env`` reads it best-effort
inside a try block. Bind to a local with type:ignore so mypy stops
flagging the access without affecting runtime.

* Mark unused action arg in sim_fn to satisfy pylint

The simulator callback signature must match StepSimulatorFn's
(state, action, params) shape even though apply_rules doesn't use
the action. Renaming to _action signals intent and silences pylint's
unused-argument check.

* Use per-component diff in _set_state to eliminate robot jitter

Replace the all-or-nothing kinematic-match gate with a per-component
diff: robot pose, each object pose, and held-object identity are each
compared against the live PyBullet world and only re-written when they
actually differ. _robot_matches_state now compares at the joint level
(the prior EE-quaternion path hard-coded roll=0, which spuriously
mismatched whenever the wrist had any roll and forced a full reset on
every simulate() call). reset_state honors caller-provided
joint_positions only when they reconstruct the requested EE pose,
falling back to IK otherwise.

* Reposition recreated cups and plugs in coffee _set_domain_specific_state

_remake_cups creates fresh PyBullet bodies that need to be teleported
to their state-specified poses; the per-component diff in _set_state
now skips objects whose pose already matches PyBullet, so the explicit
_reset_single_object calls ensure freshly-recreated bodies land in the
right place. Same treatment for plugs when coffee_machine_has_plug.

* Look up predicates lazily in option-model _abstract_function

The lambda used to capture predicates at __init__ time, which missed
predicates invented later (grammar search) and broke subclasses whose
_get_current_predicates depends on attributes not yet set during
super().__init__().

* Rename 'kinematics-only' to 'base-sim-only' in docs and test names

Terminology cleanup to match how skip_process_dynamics is described
elsewhere; the env wraps the full base sim, not just kinematics.

* Tighten _robot_matches_state atol so set_state hint forces reset

The fast-path joint-match check used atol=1e-2, which let a caller's
initial_joint_positions hint be silently treated as "already there"
when live joints were within 1e-2 of initial — leaving the EE pose up
to ~3e-3 off the requested state. State.allclose compares features at
1e-3, so the test then failed reconstruction. Match the State.allclose
tolerance.

Also pick up trailing yapf reformatting in two approach files.

* Fix flaky test_glib_explorer and test_demo_dataset_loading under pytest-split

Both tests pass on master and in isolation but fail on shards 6/8 of CI
on this branch. The branch's new tests shifted pytest-split's
least_duration distribution so existing tests landed in different shards
than on master, exposing pre-existing fragility:

- test_glib_explorer[Holding]: score_fn returned 0 (not -inf) for
  non-target goals, so they weren't filtered. With cover's 7-atom
  dynamic universe and 10 babbles, ~3.5% of seeds sample no Holding goal
  and the explorer falls through to a Covers goal, leaving the final
  state without Holding. Bumped glib_num_babbles to 100 and switched
  the test's score_fn to return -inf for non-target so the explorer
  never plans toward an off-target predicate.

- test_demo_dataset_loading[10-True-oracle-...]: _ensure_cover_demo_
  data_exists only checked file existence. test_demo_dataset's
  max_initial_demos block writes a 3-trajectory dataset under the
  cover__demo__oracle__7__... name; the [10-...] case then loaded 3 +
  generated 3 = 6, expected 10. Added a trajectory-count check so the
  helper regenerates partial files.

* Add unit tests for _robot_matches_state atol and pybullet_helpers.objects

- test_robot_matches_state_atol_forces_reset_on_small_drift: locks in
  the 1e-3 atol regression. A ~5e-3 joint drift (within the previous
  1e-2 tolerance, outside the new 1e-3) must NOT be treated as
  "already there" by the fast-path; _set_state must move the robot
  back to the requested EE pose at State.allclose precision.

- tests/pybullet_helpers/test_objects.py (new): coverage for
  sample_collision_free_2d_positions, used by 3 PyBullet envs but
  previously without direct tests. Covers no-overlap (circles and
  rectangles), bounds, reproducibility across seeds, RuntimeError on
  impossible packing, and ValueError on unknown shape_type.

---------

Co-authored-by: Yichao Liang <ycliang@Yichaos-MacBook-Air.local>
Boxes created via `create_pybullet_block` were applying the same
friction coefficient to lateral, spinning, and rolling channels. With
`_obj_friction = 1.2`, a cube that landed on an edge or corner stayed
frozen there: spinning/rolling friction supplied a contact torque equal
and opposite to gravity's tipping torque, so the cube never settled
onto a flat face.

`create_pybullet_block` and `create_pybullet_sphere` now apply
`friction` only to `lateralFriction`. Spinning and rolling friction are
new optional kwargs, defaulting to 0.0 (PyBullet's own defaults). The
fan ball and the domino ball — which actually want resistance to
spinning around the contact normal — now pass `spinning_friction`
explicitly to preserve their prior behavior. Other dynamic bodies
benefit from the fix; static bodies are unaffected because PyBullet
ignores friction torques on zero-mass bodies.

Also add `if __name__ == "__main__":` GUI entry points to
`pybullet_blocks.py` and `pybullet_cover.py` (the only concrete pybullet
envs that lacked one) so they can be launched as scripts the same way
as `pybullet_fan.py`.
)

* Make human_low_level_control approach work across all pybullet envs

The approach previously assumed (a) simulator_state is always the dict
PyBulletEnv builds, (b) the robot type exposes x/y/z + tilt/wrist
features, and (c) only fan/blocks/coffee/circuit exist. This crashed on
pybullet_blocks (list-shaped simulator_state, pose_x/pose_y/pose_z
features) and silently fell back to fan's robot for any other env, so
IK ran against the wrong base pose.

- Tolerate raw-list simulator_state; defer physics_client_id/robot_id
  lookup into the mobile-base branch where they are actually used.
- Read the current EE pose via shadow_robot.forward_kinematics() so the
  policy is independent of env state-feature names (fixes blocks and
  cover, the latter being a 2D env with no y feature). Tilt/wrist
  deltas now apply on top of the FK orientation, preserving roll.
- Look up the active PyBulletEnv subclass via the registry and build
  the shadow robot from a fresh DIRECT client + plane + the env's own
  _create_pybullet_robot, skipping each subclass's body-loading
  override (tables/blocks/fans/etc. are not needed for IK).

Smoke-tested across all 14 pybullet envs (ants, balance, barrier,
blocks, boil, circuit, coffee, cover, fan, float, grow, laser,
magic_bin, switch).

* Autoformat and lint fixes for human_low_level_control approach
amburger66 and others added 29 commits July 31, 2026 15:18
* 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.

* 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.

* 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.
* 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.

* 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.

* 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.

* 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.
…xes (#111)

* Add exp config: high-friction condition on the real-scene twin

Runs the friction mismatch from exp_domino.yaml's domino_high_friction_turn
arm (true 0.5 / planning 0.1) against the reconstructed real scene instead of
generated min-block tasks, with the margin AL agent.

pybullet_domino_real overrides task generation, so only the physics half of
that arm transfers. The min-block geometry flags, domino_test_turn_ratio, and
domino_block_cost are deliberately omitted: the first two are read only by the
min-block generator this env never reaches, and the third rides on a
DominoEvaluator that _task_from_perceived does not attach. The file's header
records this so the omissions do not read as oversights.

The scene path is pinned because the envs/all.yaml default names a capture
that is not checked in.

* Run the domino system-ID arm on the Franka: new env + two Panda-only fixes

Adds pybullet_domino_real_geometry: pybullet_domino's own generated tasks
(min-block spans, turn legs, K*, DominoEvaluator) staged on the real scene's
robot setup -- Panda on its short pedestal, extended table tile. The dominoes
stay the SIMULATED ones, so exactly one variable moves against
domino_high_friction_turn and a difference between the arms is attributable to
the robot.

RealSceneGeometryMixin is split out of PyBulletDominoRealEnv so the two axes of
"real" compose independently: that env pairs the geometry with tasks rebuilt
from a perceived scene, this one pairs it with generated tasks. The mixin is
deliberately not a BaseEnv subclass -- create_new_env resolves envs by scanning
get_all_subclasses(BaseEnv), so an intermediate env class would inherit
PyBulletDominoEnv.get_name() and shadow pybullet_domino.

Two bugs surfaced by the robot swap. Both are silent: no error, just a skill
that reports success and does nothing.

1. Grasp. The Panda's default closed_fingers is 0.03 PER FINGER -- a 60mm gap
   around a 15mm domino. A grasp registers only when a finger comes within
   grasp_tol_small (0.5mm), so Pick ran all four phases, reported terminal
   success, and lifted air: is_held stayed 0 and the domino never moved.
   Fixed by pybullet_closed_fingers: 0.007 (clamps with ~1.6mm/side). Only the
   Panda reads this setting, which is why it never came up before.

2. Release. _get_robot_state_dict wrote the raw finger JOINT into the State's
   fingers feature without _fingers_joint_to_state, while _extract_robot_state
   applies _fingers_state_to_joint on the way out -- so the skills' fingers
   helper re-converted an already-joint value and every reading came back
   deflated. Finger targets became unreachable and Place spun in ClearFingers
   to max_num_steps_option_rollout (1000 actions) without ever releasing,
   blowing the 500-step horizon. Invisible on the Fetch, whose joint endpoints
   equal the State ones (0.01/0.04) so the map is the identity; pybullet_blocks
   already applied the inverse in its own override.

Measured on the same plan, same params: Place went 1000 actions (capped, never
released) -> 23, and a Pick+Place pair 1085 -> 96 steps. Across three seeds the
arm then produced 7 certified train solves and one test solve (reward 0.80),
with system-ID moving friction 0.1 -> 0.53-0.66 against a true 0.5; the
pre-fix run had zero successes.

Straight spans (0.29-0.31) were re-probed on this setup and transfer unchanged
(true K*=1 vs believed 2, 3/3 reps). The TURN legs do not, and are left at the
Fetch values with the blocker documented inline: recalibrating them is gated on
a separate bug where straight_span_k_star depends on CFG.seed for the Panda
(k=1 at seeds 0/1 vs 4/None at seed 2, converging once motion planning is off).
Consequence: the test split is currently empty on this arm and reports
"Tasks solved: 0 / 0"; training is unaffected. That work is parked on
worktree-domino-calibration-probe with its evidence.

* docformatter: rewrap PyBulletDominoRealEnv summary line

CI runs docformatter 1.4 with --check; the class summary wrapped one word
early after the docstring edits in the previous commit.
setup_sandbox_directory wrote CLAUDE.md, validate_sandbox.py, settings.json
and notes.md with bare Path.write_text(), which encodes using the locale's
preferred encoding. VALIDATE_SANDBOX_SCRIPT contains an em dash at index
3026, so under a C/POSIX locale the write died with

    'ascii' codec can't encode character '—' in position 3026

Sandbox setup runs once per agent query, so this was not a single bad
query: every query and every final-submission nudge failed identically,
and the task ended with "no captured plan after 0 completed agent
queries" after exhausting all solve attempts. The traceback never named
the sandbox, because the approach catches the failure per query and logs
only the message.

The system-prompt write a few lines further down already passed
encoding="utf-8"; this makes the rest of the function consistent with it.

Verified by running setup_sandbox_directory under LC_ALL=C with C-locale
coercion disabled: origin/master raises the error above, this commit
completes and writes every file.
…arm (#112)

* domino_real: a dry-arm rung, and carry sampled plans from sim to the arm

Validating the domino skills on the real Franka had a gap on either side
of the one tool that ships motion.

replay_plan could only be run two ways: pure sim, or the arm moves. It
took real_robot_dry from the config, where nothing sets it, so --execute
went straight to metal. --dry adds the rung in between: the whole
RealRobot minus the arm, so attachment, per-option chunking and the
gripper split all run for real and nothing moves. It needs no hardware
powered on, which is what makes it a rung you actually take.

It also forced observe/human_reset off while leaving perception at the
"zed" default -- and RealRobot opens its perception session at
construction, so a replay that is hard-forced never to look was still
holding both cameras open, and failed when they were busy or unplugged.
Pin it to "none".

probe_real_scene samples good option parameters with the oracle samplers
and then dropped them on the floor; replay_plan wanted those same numbers
typed in by hand. --dump-plan writes the grounded plan in replay_plan's
format, so the plan that reaches the Franka is the one that was just
watched working in sim rather than a fresh sample from the same sampler.
simple_str() is parameter-free by design, so the line format is built
here.

Both scene defaults pointed at capture files that are no longer on disk
(domino_real_0000.json); repoint them at domino_straight.json.

* probe_real_scene: wrap a docstring line to 80 chars

predicators lints at 80 (.predicators_pylintrc + .style.yapf), not the 100
the sibling babyrobot repo uses; one docstring line came in at 81 and failed
the pylint gate. Verified with the repo's own rcfile this time, plus yapf,
isort, docformatter and mypy.
… Place-in-a-sketch (#114)

* probe_real_scene: ground the sketch lazily, and find InFront in the env

Two bugs kept Place out of every sketch since #54, so the documented
grammar 'Pick:1 Place:1@6 ...' never worked -- only Push/Pick/Wait did.

1. Every token's parameters were sampled against task.init before anything
   ran. _place_option_sampler requires a domino already held (is_held > 0.5),
   which does not exist until Pick has executed, so chaining Pick into Place
   died with 'expected one held domino, found 0'. Ground one token at a time
   instead, through option_policy_to_policy's existing hook: it asks for the
   next option only once the previous has terminated, and hands over the
   state at that moment -- exactly the state the next sampler needs.

2. InFront was looked up in the excluded-predicate-filtered set. The stock
   config (exp_domino_real.yaml) excludes InFront from the LEARNER's
   vocabulary, so the lookup silently yielded None, every @ref was dropped,
   and the placer then failed with 'no InFront subgoal references the held
   domino'. The subgoal is an instruction to the oracle sampler about where
   to aim, not part of the agent's vocabulary, so read it from
   env.predicates.

Behaviour is unchanged for sketches that already worked: 'Push:start Wait'
still grounds to Push(robot, domino_2)[0.0450, 0.0825] -- the same numbers,
from the same seed, as the plan run on the Franka.

Not fixed here, and worth knowing: _place_option_sampler only knows
placements off a reference within 10 deg of cardinal (_is_cardinal), because
its candidates come from the task generator's grid geometry. Perceived
dominoes routinely miss that -- 2 of the 4 in domino_straight.json are ~12.5
deg off -- so @ref only works against a near-cardinal reference.

* domino: correct Pick/Push heights for the hand actually in use

Both z offsets position the TOOL frame, but what has to grasp or strike the
domino is the hand hanging below it -- and the two arms differ. Measured at
home (lowest finger-link AABB against the tool link):

    fetch  0.0320 m below the tool frame
    panda  0.0152 m

The offsets were tuned on the Fetch, so the same 0.0825 puts the Fetch's
fingertips at 84% of a 0.15 m domino's height and the Panda's at 95% -- the
very top edge. On hardware the Panda barely caught the tops on a grasp, and
on a push it skimmed over the domino entirely: every sim rollout on
domino_straight.json reported toppled={all False}.

Subtracting the difference (0.0168 m for the Panda, 0 for the Fetch) makes
grasp and contact height a property of the domino rather than of the arm.
Fetch values are unchanged by construction, so nothing tuned on it moves.

With this, Push topples the start domino in sim for the first time:
toppled={'domino_2': True}.

* real robot: release at the drop pose, not at the top of the retreat

_split_actions classified the hand by midpoint, so a commanded width had to
travel halfway to open before the bridge called it a release. Place does not
do that: with release_until_ungrasped it opens just enough for the simulator
to drop the grasp, then a few millimetres to clear the object, and only fully
opens in its final phase -- which runs AFTER the retreat, at transport height.

The bridge therefore read 'closed' through the release, the clear and the
whole retreat, and shipped its single open at the very end. On hardware that
carried the domino up and dropped it from height.

The real hand is binary, so any width meaningfully wider than closed is a
release. Splitting a Place-shaped sequence now gives
close, move(carry), open, move(release, clear, retreat) -- the open arrives
before the arm leaves the table.

* domino_real: home the wrist where the base domino env does

The real-scene override set domino_real_robot_init_wrist to 0.0 while
PyBulletDominoEnv uses -pi/2, so the real envs homed the hand a quarter turn
from every other domino env -- the fingers opened across the domino's broad
face instead of its thickness, on the arm and in the twin alike.

Confirmed in simulation; not yet checked on hardware.

* real robot: close on a grasp tighter than closed_fingers, and test as Panda

Two fixes CI caught, both mine.

The gripper split's new rule was symmetric -- abs(v - closed) <= tol -- which
is wrong in the tighter-than-closed direction. A real Pick descends through
finger values well BELOW closed_fingers (0.0 up to 0.0125 against a closed of
0.02 on the real scene), so every carry waypoint read as an open hand and the
whole trajectory collapsed to one `open` and 75 waypoints: the gripper would
never have closed at all. One-sided now, which is what the old nearest-value
test was really protecting. Checked against a real Pick -> Place trajectory
rather than a synthetic one; it splits as

    open, move(30) descend, close, move(33) carry, open, move(12) retreat

i.e. the release lands at the drop pose and the retreat follows it.

The real-scene tests built the env with the DEFAULT robot, though every
shipped config sets pybullet_robot: panda and the env exists for a Panda on a
pedestal -- one _config's docstring even claims it applies "the config this
env is actually run with". That went unnoticed while the home orientation
happened to suit both arms; with the wrist homed where the base domino env
homes it, the Fetch's IK cannot converge and 20 tests failed. Set the robot
in all four real-scene test configs.

The nearest-value test is replaced by two: any widening is a release, and a
grasp tighter than closed_fingers stays closed.

* real robot: judge a release against the grasp width, not an absolute mark

Both absolute rules tried here were wrong, and in the same way: they
anchored the release test somewhere the release never has to cross.

A nearest-value test kept the hand shut through the release AND the
retreat, so the object was dropped from transport height. Anchoring at
closed_fingers only moved the problem -- a firm grasp settles far tighter
than closed_fingers (a real Pick on this scene bottoms out at 0.0 against a
closed of 0.02), so the entire release still sat under the mark and the one
`open` again landed at Place's FullyOpenFingers, at height. My check of that
fix read the trailing move as the retreat; it was the full-open phase, and
the retreat was inside the block before it.

The release is grasp-relative, so the test has to be too: track the tightest
width since the hand closed and treat a widening beyond _RELEASE_EPS as the
release. Two guards fall out of the measured trajectory:

  * the closing motion is non-monotonic (0.010 -> 0.01183 -> tighter), so the
    epsilon sits above that wobble and below the skill layer's
    _RELEASE_OPEN_STEP, at half the step;
  * after releasing, the hold width is STILL under closed_fingers on a firm
    grasp, so re-closing needs a real tightening below where the hand let go.
    Without that the hand clamped shut again one action after opening.

Verified against a rolled-out Pick -> Place with EE height, rather than by
reading the segment shape:

    action  0  open   ee_z=0.9273
    action 30  close  ee_z=0.5416   (grasp, at the table)
    action 51  open   ee_z=0.5802   (release, 3.9cm up)
    end        ee_z=0.7357          (retreated, after the release)

_RELEASE_EPS is a local copy because skill_factories imports pybullet_helpers
and importing it back would invert the layering; a test pins it against
_RELEASE_OPEN_STEP so the two cannot drift.

* real robot: tighten the release-rule comments

Both were saying at length what one line says.

* shorten comments

* replay_plan: --observe, so the closed-loop rung has a vehicle

The bring-up doc's closed-loop stage says to use replay_plan with
real_robot_observe_at_option_boundary on, but the tool hard-forces it off --
and #112 pinned perception to "none" alongside it, because a run that never
looks should not hold both cameras open. Between them that stage had no way
to run at all.

--observe opts into the mid-replay correction and takes perception with it.
The default is unchanged: no look, no cameras, reproducible replay.

Deliberately a flag rather than a default. Correcting the twin between options
lets the option policies see states the recorded plan was never chosen
against, so the run stops being a pure replay -- which is precisely what the
rung is testing, but is wrong for every other use of this tool.

* domino_real: perception must not move a domino the gripper is holding

Perception snaps every domino to a resting pose on the table -- snap_stable
sets the centre height from the known vertical extent -- so it cannot see one
in the gripper and reports it lying where it would be if the hand let go. A
real run showed all four dominoes at z=0.034 including the carried one.

state_from_observation wrote that in regardless, which teleports the domino
out of the hand; _set_state then treats the held object's pose having moved as
a reason to rebuild the grasp constraint, so the twin ends up holding
something that is not where the gripper is. Reproduced in simulation with
replayed perception: the correction reports 0.222 m of divergence at the
boundary. Downstream the run may survive or die on an IK timeout, which is
why closing the loop over a Pick -> Place was stochastic.

Skip held dominoes: the twin's belief wins for whatever it is carrying, which
is the one thing perception structurally cannot check.
* real robot: a fixed-plan explorer, and a record of every look

Three pieces for exercising the online loop on hardware, where the cost is
wall-clock rather than compute.

fixed_plan explorer. The planning explorers cost minutes per episode, so a
full cycle is expensive to run for the sake of testing the loop around it.
This one replays a plan file every episode and costs nothing, so a run that
goes wrong is the loop's fault and not the planner's. It reads replay_plan's
format, which means a plan dumped by probe_real_scene --dump-plan and already
verified through replay_plan can be handed straight to the loop. Grounded per
episode, because a human reset rebuilds the task and hands back fresh Objects.

Every look is logged, not only the ones over tolerance. A run whose looks all
behaved previously said nothing at all, so there was no way to distinguish
"perception was healthy" from "nobody checked". The line carries the
per-object breakdown, which is what tells a single knocked domino apart from a
table-height offset shared by all of them -- the max alone cannot.

Looks can be dumped to JSON (real_robot_observation_dump_dir, off by default).
Each file records what was perceived beside what the twin predicted, so a
session can be re-examined offline without the robot. Write failures are
logged and swallowed: losing a dump must not take the arm down mid-episode.

The learner is untouched.

* configs: a cheap rehearsal of the Stage 6 loop

Stage 6 proper is exp_domino_real.yaml -- live cameras, a live arm, a human
reset per episode, a planning explorer costing minutes an episode. That is
what to run once the loop is trusted, and the wrong thing to debug the loop
with.

This keeps the loop shape (explore, learn, test) and makes one pass
survivable: the fixed_plan explorer replays a verified plan instead of
planning, nothing moves, no camera opens, and every look is dumped. It runs
with the robot powered down.

It does not replace the learner, which is separately expensive; the header
says so.

* real robot: the boundary check must not disturb a stateful terminal

Wait counts consecutive settled steps in its own memory, and its option
policy already consults it once per step. OptionBoundaryBuffer consulted
it a second time, so Wait judged the scene settled in a third of the
steps it really takes.

The check now restores the option's memory, making it a pure query.

* real robot: a twin correction is a resync, not the scene moving

Wait ends once the scene holds still for several consecutive steps.
Writing perception into the twin moves objects without the scene having
moved, so Wait counted every look as motion, zeroed its tally, and never
saw the scene settle -- the episode then ran to the step cap instead of
ending when the plan did, spending a look every few steps on the way.

rebaseline_quiescence re-seeds the baseline and keeps the tally, so the
jolt is skipped rather than counted.

* rename rebaseline_quiescence to note_external_state_change

The old name described the mechanism, so a reader had to already know
what a quiescence baseline was to guess what the call did. The new one
names the event instead: the state was set from outside rather than
moved into, which is the thing the caller actually knows.
* domino_real: let a real task carry a DominoEvaluator

A task built from the real scene carried no evaluator, so every episode
scored reward 0.0 and an over-built chain was indistinguishable from a
minimal one. That difference is exactly what the friction-mismatch
experiments are read off, and it is what DominoEvaluator computes:
each movable domino the cascade consumes costs domino_block_cost.

Opt-in via domino_real_attach_evaluator, so existing runs score as
before. The movable count comes from the perceived scene rather than
domino_min_block_num_blues -- that flag is a generator budget, and a
real scene stages whatever the person put on the table.

The evaluator asserts that a success outscores any failure; that would
fire mid-episode on the real robot, so the bound is checked at task
construction and reports the scene's own numbers instead.

* configs: Stage 6 launchers, and score the rehearsal's episodes

stage6_domino_real.yaml is Stage 6 proper -- exp_domino_real.yaml with
execution on. Everything else the stage needs (live cameras, a look at
every option boundary, a human reset per episode) is already the
settings.py default, so it sets only what differs.

stage6_domino_real_underreach.yaml adds the REVERSE friction mismatch:
the planner believes the dominoes are far more slippery than they are,
under-estimates reach, over-builds, and pays for the surplus. Only
domino_planning_friction moves -- the twin keeps modelling the real
table. Its mirror does not transfer to hardware, where the twin is not
the ground truth; the file says so.

Both cap wait_option_max_steps, whose shipped bound is inf: a Wait that
never sees the scene settle otherwise runs to the horizon and takes a
human reset with it.

The rehearsal now attaches the evaluator, which is the cheap place to
learn whether the cascade certificate accepts an episode assembled from
perception rather than physics.

* real robot: a config asking for no cameras gets none

utils.string_to_python_object maps both "None" and "none" to Python
None, so real_robot_perception: "none" from a launcher config never
arrives as the string _make_perception was matching on -- it arrived as
None and fell through to the unknown-kind ValueError. The mode was
reachable only from a direct reset_config call, which is why the
existing test (which does exactly that) never caught it.

Also adds the evaluator-check launcher: perception off and no looks, so
the twin keeps what the plan built and the cascade can actually run.
That is the cheap place to learn whether DominoEvaluator's certificate
accepts a real-shaped episode, which the rehearsal cannot answer
because scene_file perception undoes every Place.

* domino_real: attach the evaluator on the generator's own terms

Replaces domino_real_attach_evaluator with the condition
DominoTaskGenerator._make_task already uses. What decides whether a
verdict means anything is the certificate's causal model, not which env
built the scene, so the real env should not need a flag the simulated
ones do not have -- and defaulting it off meant the real path silently
scored 0.0 while every sim path scored.

Both of the generator's conditions hold for a real scene: every shipped
domino config sets domino_use_domino_blocks_as_target, and a real scene
is dominoes and nothing else, so there is no additional dynamic
component to topple them without a Push.

Also carries across the scoring text the generator appends to goal_nl.
Its comment records why it exists (run_20260716_215533 burned its budget
theorizing that any disturbed blue disqualifies a solve); an agent
scored on the real robot would have hit the same misreading.

* clean up configs

* submodule: update BabyRobotPredicator to main

Brings in the auto-launch (#59), so a restarted droid server binds its
robot handles instead of failing every read with 'FrankaRobot object has
no attribute _robot', and the safety box (#61), which confines the
end-effector and resolves fail-closed.

The pin was at a 2026-07-28 commit that predated both.

Cherry-picked from master; its exp_domino_real.yaml scene line is
dropped in favour of the newer one already on this branch.

Note this does NOT include BabyRobotPredicator #63 (the measured table
height), which is still open -- the submodule wants bumping again once
it lands, or a scene captured through the submodule's own scripts will
carry the 4 mm z error back.
Picks up the measured-table-height work: the capture path's -0.045
defaults now read SkillConfig.real_table_z_base (-0.041), so a recapture
through the submodule no longer reproduces the 4mm error.

No behaviour change for predicators itself -- real_robot_bridge passes
table_z=CFG.domino_real_table_z explicitly, so DominoPerception's new
default is a no-op here. It matters for the babyrobot capture CLI, which
takes the defaults.
* real robot: one arrangement serves both task splits

tasks_for consumed a single _reset_pending token, so whichever split
asked first got the perceived scene and the other kept the captured-scene
task. With the online loop off, main.py requests the train tasks during
setup -- so the TEST task, the one that actually gets solved, silently
stayed on the scene JSON while the arm executed in the real scene.

Seen in run_20260806_132702: the agent's sim.state matched the JSON-derived
pose to 1e-16 (bit-identical, where a camera would differ by millimetres).
It tuned a single-bridge plan to 20/20 against those poses; on the arm the
Push was refused before moving, its approach waypoint 9.4 mm inside the
bridge domino it had just placed.

The look is now kept and both splits rebuild from it. A physical reset
arranges one scene and the person who arranged it meant it for whatever
runs next, so train and test are the same task -- which is what a real
bench means anyway.

(cherry picked from commit 035a1e1)

* real robot: refuse the captured scene while the cameras are live

The scene JSON's poses are a snapshot. Planning against them while the
arm works a scene nobody looked at plans for a world that is not there,
and it is silent: planning succeeds, and the twin only jumps to the
truth at the first option boundary.

tasks_for now raises when live perception is configured and no look has
happened, naming both ways out. Replaying a recorded plan is the one
case that wants those exact poses -- the plan was written against them --
so replay_plan opts in via real_robot_allow_captured_scene_task, and the
two test suites that deliberately run against a capture say so too.

Does not remove the JSON: _scene_ids (the capture-id to slot map, without
which a live observation names nothing) and the slot counts are read in
__init__, before any camera exists. Dropping it entirely means capturing
at construction, which is a separate change.

* domino: narrow Pick's grasp box on short-fingered hands only

The shipped grasp_z_offset box (0, 0.1) was drawn around the Fetch: its
top edge IS the Fetch's reach edge, and the collision edge at 0.045 sits
45% up, leaving the top 55% feasible. Sweeping a real domino at 5 mm,
the Panda collides at the same 0.045 -- that edge belongs to the domino,
not the hand -- but stops reaching it past 0.080, so only 35% of the same
box can work and a sampler spends most of its budget on offsets that
cannot.

Give the Panda the Fetch's proportions around its own reach edge rather
than shrink-wrapping its band: same feasible fraction, same shape of
learning problem, so a sampler compared across the two arms is comparing
embodiment and not box width. The reach edge comes from
_hand_z_correction, which is zero on the Fetch -- so the Fetch keeps the
shipped box and its parameter description untouched, and a future hand
gets bounds without another sweep.

Panda: [0.0137, 0.0832], 35% -> 50% feasible. The tuned sampler value
(0.0657) and every Pick in plans/*.txt stay inside it. create_pick_skill
grows an optional param_defs that defaults to the canonical box, so
coffee, bridge, grow and boil are unchanged -- their objects were never
measured, and guessing bounds for them would be worse than leaving them.

(cherry picked from commit bf9133d)

* domino: pin the Place drop-height test to the constant, not 0.58

2403bf2 lowered _DOMINO_DROP_Z to 0.568 and left this assertion behind,
so the sampler test has been failing since. What it means to check is
that the sampler uses the canonical drop height, not that the height has
one particular value.

(cherry picked from commit 5923ce1)

* domino_real: a look must not turn the opening push around

Push takes its entire direction from the start domino's yaw
(push.py: facing = (sin(yaw), cos(yaw))), and a domino is 180-degree
symmetric, so perception returns whichever heading branch it likes.
task_from_observation already knew that and flipped the start to face
the target; state_from_observation deliberately did not, on the grounds
that mid-episode the start may already have been pushed.

But every option boundary before the push is a look, and each one wrote
the raw branch back. A plan that stages a bridge and then pushes gets
four looks first, so by the time Push read the yaw it was perception's
arbitrary choice. On the real scene that choice is -1.727, which faces
AWAY from the target: the arm pushed the start into open table.

Canonicalize on the correction path too, but only while the start is
still standing. That keeps the original reason intact -- once it has
gone over there is no push left to orient, and flipping it would
misreport which way it fell -- and it is the same fallen_threshold guard
_task_from_perceived already uses.

_target_xy prefers the target's pose from the current observation and
falls back to the twin's last known one, matching this path's
"absent means unchanged" policy: a target hidden behind the arm must
not cost the start its heading.

Measured on the real scene file: start yaw after a look goes from
-1.7269 (away) to +1.4147 (toward). The replaced test pinned the old
contract; the two new ones pin both halves of the new one.

* domino: a domino set down crooked is standing, not falling

Two real episodes toppled the target through a legitimate cascade and
were both rejected at reward -0.15, the certificate reporting that a
bridge domino "started falling at step 21" -- the step the gripper
closed on it, 100 steps before the push.

_topple_onset dates a fall by finding when the domino ended up on its
side and walking back to the last moment it was standing within the
upright band (5 degrees). The walk-back skipped held states with a
`continue`, so it ran straight through the pick-and-place. A real
placement does not land at exactly zero roll, so the domino rests a few
degrees off plumb and never re-enters the band; the search therefore
kept going past the grasp to the last time it stood untouched on the
table, and dated a cascade that arrived long after the push to before
it.

Two changes. The walk-back now floors at the step after the domino was
last released: it cannot have been falling since before the robot picked
it up and carried it. And a domino that was staged and then rested below
fallen_threshold until something reached it has its fall dated to the
fall itself -- resting crooked is standing.

The rule keeps its teeth. A robot that drops a domino over lands the
full topple at the release and is still caught there; scenery is
untouched (never held, so the floor stays 0); and a staged domino that
genuinely starts tipping before the push, crossing the upright band but
not the fallen one until after, is still dated to the tipping.

Sim never showed this: a placed domino settles there at exactly zero
roll, so the walk-back always found an upright state just before the
real fall. It needed imperfect placements, i.e. hardware.

* tests: isort's wrapping for the drop-height import

CI runs isort --check, which wants the continuation on the second name
rather than both.

* Revert the Place drop-height test tidy-up (c6067cc, 1a47143)

Those two were here only to keep the assertion working once #117 lowered
_DOMINO_DROP_Z to 0.568, plus the isort wrapping that followed. #117 is
closed -- the drop height stays 0.58 for every embodiment, having only
been tested on the Panda -- so the literal is correct again and
c6067cc's stated reason ("the sampler test has been failing since") no
longer describes anything on master.

Leaving it would put a message referencing a commit that never landed
into the history of a PR that is otherwise only hardware fixes. Worth
doing on its own merits later: what the test means to check is that the
sampler returns the canonical drop height, not that the height is any
particular number.
Slurm sbatch wrapper + launcher for the Engaging (ORCD) cluster; predicators imports deferred into _run() for fast CLI startup on login nodes.
Make the physics-margin arm the main al arm; run the fan al arm demo-free; 3 seeds per experiment; swap the active fan arm to agent_oracle_hybrid_sim; fan_test_num_pos_y 5 -> 6.
Read run liveness from squeue when serving on a login node; pin Slurm jobs and moved runs to their real directories.
…ract (#123)

Explore prompts separate the delivery contract from the belief-model disclosure; solve makes one query per attempt with the delivery nudge only on the last.
… scoring (#128)

Make the registry sweep opt-in via rollout=True; add phys_params point scoring so a probe can score a parameter vector without a full registry sweep.
…stack (#125)

Mechanical rename (44 files): skip_process_dynamics -> skip_residual_dynamics, process_features -> residual_features, _process_rules -> _residual_rules, plus matching docs/tests/prompts. No behavior change.
…eck (#126)

Fix switch-snap and fan-clearance sim/real divergences; expose blocker geometry as observed state and generalize the GT contact rule to use it; add the fan model-learning postmortem slide deck.
…ible base sim + hidden dynamics (#127)

Learned residual rules can queue engine-executed physics commands (ApplyForce/ApplyTorque/SetVelocity) instead of re-deriving contact geometry as feature overwrites; the fan env splits into a visible base sim and hidden wind/task-gen/predicates, the env's own wind routes through the same command executor, and command rules fit via free-running rollout matching. Base-sim source provisioning is gated by agent_sim_provide_base_sim_source.
* cascade certificate: a topple must persist, not just occur once

A real domino run (20260813_112747) reached its goal and was rejected with

    domino_0 started falling at step 31, before the green start block was
    first pushed (step 129)

Step 31 is not a fall. It is the step the gripper closed on domino_0, ~100
steps before the push. Markerless perception of the same episode shows the
block never left the upright band before the push: 0.20 deg median for the
30 s it stood placed, peaking at 12.75 deg only while the gripper occluded
it, and zero frames past the 15 deg upright threshold anywhere before the
push. The cascade was real, arrived during the Push, and propagated down the
row in the intended order in 368 ms.

What fired instead was one state of release transient. `_topple_onset` took a
single non-held state at or past `fallen_threshold` as a topple, and the state
immediately after `is_held` clears catches the block mid-release, still
settling: measured at 4.9 deg against a 5 deg upright band, while a domino in
the gripper swings to 13.1 deg -- past `fallen_threshold` -- routinely, masked
only by `is_held`. One state at 10 deg therefore carries no information. The
spurious fall then landed before the push and the episode was rejected.

So require `_TOPPLE_MIN_STEPS` (3) consecutive non-held states at or past the
threshold. A real topple grows monotonically and stays there for tens of
states; the release transient decays back inside the upright band within about
two. The run restarts on a carry as well as on standing back up, since the
tilt of a block in the gripper says nothing about one on the table.

A run that reaches the end of the episode counts whatever its length. A topple
in the last states has no room to persist, and discarding it would leave
`onsets` empty and pass the episode unexamined -- trading this false reject
for a false accept, which is the worse direction for a certificate to fail in.

The onset is the first state of the run, so the existing backward search and
its carry floor are unchanged.

Six tests: the one-state transient and one just short of the minimum stay
quiet, a sustained fall after release still registers at its first state, runs
ending the episode register at every length below the minimum, a carry never
accumulates, and a regression case reproducing the run above -- pick, place
with a settling transient, push much later, cascade -- now certifies. The
first and last fail with the minimum set back to 1.

* shorten docstrings/comments
* panda: stop fingers crushing through grasped objects

Skills command finger targets past a grasped object (Grasp targets
closed_fingers - 0.01; 'closed' move phases nudge 1mm/step with no
floor) and rely on contact to stall the fingers. That works on the
Fetch (damped, light fingers) but the Panda URDF had no finger-joint
damping and no inertials (PyBullet defaults each link to mass=1,
identity inertia), so position control with PyBullet's default
unlimited motor force drove the fingers straight through a grasped
domino: ~400 N sustained squeeze, fingers fully closed inside the
object, flaky transport (constraint jitter) and objects knocked over
on release.

- panda_arm_hand.urdf: real Franka hand/finger inertials (0.73 kg /
  0.015 kg) and finger-joint damping 5.0 (must stay below 2*mass/dt
  = 7.2, or the explicitly-integrated damping oscillates once motor
  force is finite).
- set_motors: new per-robot finger_motor_force cap, re-issuing the
  finger motors with a finite force. Panda uses its 20 N URDF effort
  limit; Fetch keeps None (its damping-100 fingers are only stable
  with unlimited motor authority, and it already stalls benignly).

With pybullet_closed_fingers=0.007 in pybullet_domino_real_geometry,
the Panda now grasps like the Fetch: constraint forms at first
contact, ~5 N squeeze resting at the domino faces, zero-velocity
upright release. Fetch behavior is unchanged.

* domino configs: rest panda closed fingers at the domino faces

With the finger force cap in place, retune pybullet_closed_fingers to
the principled values: the joint value is per-finger travel from the
centerline, so resting at the faces means half the domino's thin axis
(0.015 m sim -> 0.008 with the 0.0005 detection tolerance; 0.029 m
real -> 0.015). The previous values (0.007 / 0.02) commanded the
resting fingers inside the object / short of a symmetric rest.

Validated: E2E Pick in pybullet_domino_real_geometry with 0.008 grasps
at first contact, 10-13 N squeeze, upright zero-velocity release;
0.015 against a 0.029 m box in the isolated harness settles at 7-8 N
resting at the faces.
* domino Push: do not motion-plan the phases that are meant to make contact

BiRRT plans a COLLISION-FREE path, and the goal of the push stroke is the
object itself. Asked to reach a pose that touches the block without touching
anything, the planner does the only thing it can: it routes around. Measured on
a real captured scene (block centre (0.5255, 1.3091), standing top z 0.5495),
the 16-waypoint trajectory it returned for Waypoint_2 was

    wp00 (0.4802,1.3106,0.5418)  the behind point, correct side, contact height
    wp01 (0.4928,1.3374,0.5565)  lifts above the block top, swings out in +y
    wp03 (0.5118,1.3700,0.5880)  59mm to the side, 39mm above the top
    wp04..13                     travels past the block, held out at y~1.35
    wp14 (0.5752,1.3270,0.5500)  back down to exactly the top
    wp15 (0.5255,1.3091,0.5407)  drops onto the goal -- moving -x

so the block is struck by that last 50mm hop, which travels AGAINST the push
direction. The block topples backwards, away from the row it is supposed to
start. Waypoint_3 (retreat) has the same problem from the other side.

The planner never had a chance to know better: the collision set is every
object in the state bar held ones (base.py), with no per-phase exclusion, so
the block being pushed is an obstacle like any other. ``expect_contact`` reads
like it covers this but does not -- it only suppresses collision diagnostics
and silences a planning failure, and here planning did not even fail.

A contact motion is exactly what a collision-free planner cannot express, so
the two contact phases now step IK directly. Measured on two captured scenes,
the start domino's displacement along the row goes

    scene cam1:  -102.7mm (away)  ->  +102.6mm (toward), roll +90
    scene row2:  -102.2mm (away)  ->  +100.2mm (toward), roll +90

and BiRRT calls per Push drop from 4 to 2, still covering both free-space
phases. ``make_move_to_phase`` gains a pass-through that defaults to None, so
every other skill keeps whatever the config says.

This reached hardware: the arm replays the twin's joint trajectory faithfully,
so it reproduced the backwards push exactly. The bug was never in the robot.

* domino Push: derive the approach distance from the domino actually in play

``_DOMINO_OFFSET_X = 0.045`` carried the comment ``domino_depth * 3``, and that
comment is only true of the SIMULATED domino: ``pybullet_domino/env.py`` sets
``domino_depth = 0.015``. The real blocks are 0.029 (``domino_real_domino_dims``,
applied at ``pybullet_domino_real.py`` where the component is built), so the
constant was never re-derived for them and the approach point sat 30.5mm clear
of the block's back face instead of the 37.5mm it was tuned to give.

Read the thickness at call time instead. Sim is unchanged (0.045); the real
scene now gets 0.087. ``_push_sampler`` and ``_push_option_sampler`` share it.

Separate from the motion-planning fix in the previous commit and NOT the cause
of the backwards push -- that symptom reproduces at both 0.045 and 0.087, in
sim and on hardware. Drop this commit if you would rather land the two apart.

* fixup: resolve use_motion_planning explicitly instead of via **kwargs

Passing the override through a conditionally-populated ``**kwargs`` dict hid
which keyword it was for, so mypy matched it against every remaining parameter
and reported it as both a bad ``terminal_fn`` and a bad ``finger_direction``.

Name it instead. ``None`` still means "whatever the config says": Phase's own
default_factory reads the same CFG flag, and both are evaluated when the Phase
is constructed, so this is the value the default would have picked. Verified
unchanged: 2 BiRRT calls per Push and the start domino still moves +101.8 mm
toward the row.

* fixup: docformatter wrapping in processes.py

docformatter 1.4 rewraps the _domino_depth description to its own width and
pulls the _push_option_sampler summary onto one line. No wording changed.

* domino Push: tell the planner which object it is allowed to hit

Supersedes the previous commit's approach of exempting the contact phases from
motion planning entirely. That fixed the push direction but threw away
collision checking on the stroke, so the gripper was no longer checked against
the OTHER dominoes or the table while driving forward.

Name the object instead. ``Phase.contact_object_index`` indexes the option's
``objects`` to say "this phase is allowed to drive into this one", and
``_plan_with_simulator`` drops that body from the collision set. Everything
else stays checked. No signature changed: that method already received both
``objects`` and ``phase``, and discarded the former with ``del objects``.

Why the existing machinery did not already cover it: run_motion_planning does
classify bodies near the robot at the start or goal as intended contact
partners, but only relaxes them to ``pybullet_birrt_contact_margin`` -- 1 mm of
tolerated penetration. A push has to bury the closed gripper centimetres into
the block, so the concept was right and the tolerance two orders of magnitude
short. Widening that margin globally is not the answer; it is what stops a
"collision-free" path from grazing a knife-edge object elsewhere.

The stroke BiRRT now returns is straight -- x advances 0.4803 -> 0.5255 in even
~4.5 mm steps with y and z flat, 11 waypoints against the 16-waypoint detour
that arced 59 mm sideways and 39 mm above the block's top. Measured:

    scene cam1:  -102.7mm (away)  ->  +95.7mm (toward), roll +90
    scene row2:  -102.2mm (away)  ->  +94.8mm (toward), roll +90

and all four Push phases are planned again, where the previous approach left
only two.

KNOWN GAP: the restricted Push variant grounds as ``[robot]`` alone, so there
is no object at index 1 and the exclusion silently does not apply -- that path
still detours. It finds its start block from state; wiring that up needs a
different hook than an index into ``objects``.

* docs: trim the push-geometry docstrings to what they need to say

The rationale for deriving the approach distance from the domino in play now
lives in the commit that introduced it, where it belongs; the docstrings keep
the statement of what the functions return. Code unchanged.

* push: correct the stale comment, drop the superseded escape hatch

The comment above the waypoint phases still described the first attempt at
this fix. It claimed the contact phases must NOT be motion-planned -- they are
-- and that BiRRT "fails outright and drops to incremental IK", which
measurement disproved: it succeeds, and the detour it returns is the bug.
Rewritten to say what the code now does and why expect_contact and the
contact-partner margin do not cover it.

Also removes the ``use_motion_planning`` pass-through added to
make_move_to_phase for that first attempt. Nothing calls it now -- pour.py
gets the same effect by constructing Phase directly -- so it was dead surface
that would only invite the question of why it exists. Phase's own default
applies again, and move_to.py no longer needs the CFG import.

Unchanged: 4 planner calls, 11-waypoint straight stroke, start domino +95.9 mm
toward the row, 55 domino tests pass.

* push: contact strokes step IK directly; revert the collision-set exemption

Reverts f9211c8 and d630549, restoring the approach of 02fd984: Waypoint_2
(the stroke) and Waypoint_3 (the retreat) are never motion-planned, and
make_move_to_phase regains the explicit use_motion_planning override.

The collision-set exemption kept BiRRT in the loop for the stroke and relied
on it returning the direct path. That leaves two holes. First, the planner
remains free to detour around a BYSTANDER near the stroke and strike the
pushed object from the wrong direction -- the original bug, one object over;
a stroke that cannot go straight should fail and be resampled, not rerouted.
Second, by its own commit message, the restricted Push variant grounds as
[robot] alone, so the objects-indexed hook silently does not apply and that
path still detours. And the checking it bought was thin: when BiRRT fails,
the expect_contact fallback runs unchecked incremental IK anyway.

The IK stroke is also the only behavior sim ever exercised. With the object
in the collision set, the stroke's goal config registers as colliding on
every sim scene, BiRRT fails the up-front goal check (utils.py RRT.query),
and the expect_contact fallback quietly ran exactly this IK stroke -- e.g.
2883 out of 2883 Push/Waypoint_2 planning attempts in one predicate-invention
run. Making it unconditional removes the knife-edge where scene and hand
geometry decide between "planner fails, fallback pushes correctly" (sim) and
"planner succeeds, block struck backwards" (the real captured scenes), so
sim and the real bench take the same code path.

The stale part of 02fd984's comment ("fails outright and drops to
incremental IK from a pose it has already contorted into"), which d630549
rightly flagged, is rewritten to what measurement actually showed. A new
unit test pins the contract: with skill_phase_use_motion_planning=True,
Waypoint_0/1 plan and Waypoint_2/3 do not.

---------

Co-authored-by: Yichao Liang <ycliang6@gmail.com>
…tion fit (#134)

* plan: open-loop execution + markerless continuous perception

The friction fit currently scores the twin against itself: of ~229 recorded
states in the Stage-6 run, 6 were real camera looks and the rest are the twin
integrating PyBullet at the true friction, so minimising that SSE recovers the
true value by construction. The agent declined to declare, which was the right
read of the evidence. This plan fixes the evidence.

Three steps. Open-loop execution ships an episode's motion in one batch instead
of one option at a time, which is safe because with observe=False shipping is a
pure write-only side effect and deferring it leaves the twin trajectory
bit-identical. Markerless perception supplies a dense real pose track, already
measured at 1.03 deg median orientation error on the better camera. Scoring
then moves to inter-domino propagation intervals, which are invariant to both
the alignment offset and the ~25-38mm extrinsics calibration offset (measured
displacements are accurate to ~1mm).

Written against both remotes as of 2026-08-14, not against their docs.

* domino real: correct the grasp offset, and sketch the bridge plans

The agent's 0.082 grasp offset failed on the real arm in run_20260807_102548:
look 2 put domino_4 0.1987 m from the twin's prediction while every other
domino stayed under 9 mm, so the gripper closed on nothing and the twin went
on believing it held the block. 0.082 puts the fingertips 8.2 mm below the top
of a 150 mm domino; in sim that grips as well as anything, because a grasp is a
JOINT_FIXED weld formed on 5e-4 proximity and grip depth costs nothing, and
higher offsets clear BiRRT more easily -- so a sim optimiser drives this
parameter to the worst real value. 0.0657 is _grasp_z_offset() for this hand.

The bridge sketches take their start and target from
domino_real_{start,target}_id rather than assuming them, and space the movables
across the measured 0.331 m gap. The 3-movable one exists because 2 movables at
0.110 m pitch only reached the target 3/8 at friction 0.1 -- low friction
shortens topple reach.

These live in scripts/plan_sketches/ and are named for what distinguishes them
-- scene, bridge size, and either the grasp offset or the fact that the roles
come from the scene's own ids -- rather than for which run happened to produce
them ("agent_best" ages badly, and said nothing about the 1-domino bridge or
the grasp it was tuned for).

Config: one exploration episode per cycle, since the fixed-plan explorer
replays the same plan and a second episode costs a full hardware run plus a
human scene reset for a near-duplicate.

* submodules: bump BabyRobotPredicator to main (396094e -> b45ac97)

Eight PRs of catch-up. The pointer sat at #63 ("use the measured table
height"), which predates the entire perception stack, so predicators could not
import any markerless or recorder code at all -- nothing downstream of it could
even be prototyped.

What this brings in: #66 ZedRecorderSession (open the ZEDs once, start/stop
many SVO takes), #67 markerless pose estimation, #68 depth-free bundles
(1.4 GB -> 48 MB, poses unchanged), #71 the stage-1 extrinsics check, #72
stage-3 fp16 + crop (20x -> ~3.2x real time) and the occlusion visibility
gate, #73 markerless scene capture as the default, #75 the pre-5.3 pyzed fix
that lets the recorder run on this machine's SDK 3.8.2 at all, and #76 output
paths that stop derived artifacts overwriting the bundle they came from.

The submodule is not checked out in this worktree, so this moves the gitlink
only; run `git submodule update --init` to populate it.

* real robot: ship an episode's motion in one batch, behind a flag

Today the twin simulates an option, ships it to the arm, then simulates the
next -- so the arm idles through the next option's motion planning before it
moves again. real_robot_open_loop_episode (off by default) holds each completed
option and ships the whole episode as a single request once it has all been
simulated.

Why deferring is safe, and not merely acceptable: with the boundary look off,
execute_chunks(observe=False) returns [], the absorb loop never runs, and
after_step already returns obs UNCHANGED. Shipping is a pure write-only side
effect, so *when* it happens cannot be observed by the rollout -- the twin
trajectory is bit-identical either way. A test asserts that equality directly,
driving both paths with a distinct observation per step so it cannot pass by
both sides being constant.

The port needed a fourth method. ActionExecutor had tasks_for/after_reset/
after_step and no end-of-episode call, which is the only reason shipping had to
happen inside after_step in the first place. after_episode(completed) closes
that gap; BaseEnv.finish_execution is a no-op so cogman can end an episode
without knowing whether the env drives anything, and PyBulletEnv delegates.

completed=False drops the buffer instead of shipping it. What survives an
abnormal end is a prefix -- half a bridge, or a transport with no place at the
end of it -- and the arm would run it with nobody having decided that was a
good idea. The information that it was partial exists only at that call, so it
is the last place the judgement can be made. after_reset drops anything left
over as a backstop, since a plan shipped against the next episode's scene is
the same hazard one episode later.

Mutually exclusive with real_robot_observe_at_option_boundary, asserted at
construction: a boundary look has to happen between the two options it
separates, and batching leaves no such moment. Batch start/end are logged from
both time.monotonic_ns() and time.time_ns(); the wall stamp is what pairs with
a recorder's own host stamp, the monotonic one survives an NTP step.

Kept off by default because it is a real regression in supervisability: the arm
runs the whole plan with the e-stop as the only intervention, where today a bad
first option is visible before the second ships.

Two duck-typed env mocks in test_cogman gain the no-op, since cogman now calls
it on the BaseEnv it is declared to take.

9 tests. Verified they fail against the old behaviour: disabling the deferral
reds 4 of them, and the bit-identical test reds when the deferred path hands
back anything other than what it was given. Full suite 1497 passed, mypy clean,
pylint 10.00/10.

* real robot: record each episode to an SVO take for offline pose estimation

Step 2 of the open-loop plan: the cameras record the whole execution and the
poses are recovered afterwards, instead of six option-boundary looks. Nothing
here estimates a pose. The markerless pipeline runs at roughly 3x real time, so
a result cannot come back inside the episode that produced it -- which is the
same fact that makes open-loop execution necessary rather than merely nice.

real_robot_record_episodes (off by default) wires ZedRecorderSession onto the
executor's lifecycle: open() once when the executor is built, start_take after
each reset, stop_take at after_episode, close at exit. Opening once matters
because a learning cycle is many episodes and per-episode camera init and
warmup would otherwise be paid every time.

Recording stops in a finally, shipping does not. They have opposite defaults on
an abnormal end: a partial plan must NOT reach the arm, but a take left open
records until the disk fills. Two tests pin the pair -- one where the episode
did not complete, one where execute_chunks itself raises.

Failures are asymmetric for the same reason. start_take raises: an episode that
cannot record spends hardware time and a human scene reset for nothing, so it
is better to say so before the arm moves. stop_take does not: by then the arm
has already moved, and a recording problem must not destroy the run around it,
so the take is logged and marked unusable. meta.json's errors list gets the
same treatment -- a camera that dropped out mid-episode yields a short track
that is perfectly well formed, which is exactly the failure worth being loud
about.

Recording and a live "zed" perception are refused together, before the env or
the hardware is examined: both open the same cameras and a ZED admits one
owner. This is a config contradiction, so it is reported as one.

Defaults chosen from the pipeline's own measurements: HD720 (what it was
measured on) at 60 fps, not 30. A real cascade's topple onsets came 6, 4 and 2
frames apart at 30 fps, and those inter-domino intervals are what the friction
fit is scored on -- at 30 fps a one-frame detection error is half the shortest
interval. HD720 already runs at 60. Exports stay off during a run; stop_take
can write depth inline, but that is the expensive offline work and doing it in
the episode loop would undo open-loop execution.

babyrobot stays a lazy import, and the recorder gets its own module-level-import
test rather than relying on the executor's: the executor imports this module at
module level, so a top-level import here would break a submodule-less checkout
just as surely, and the ZED recorder lives under pose_estimation rather than
babyrobot. mypy.ini gains the matching stanza for the same reason.

The recorder's own tests drive a stub session, which is what keeps them
hardware-free and is also how a stub silently drifts. test_real_robot_bridge
gains a contract test that pins the four calls and their keywords against the
real ZedRecorderSession, skipping without the submodule like its neighbours. It
passes against b45ac97.

12 tests. Verified load-bearing: removing the finally reds the raise case, and
ignoring meta's errors reds the unusable case. Full suite 1510 passed, mypy
clean, pylint 10.00/10.

* real robot: rebuild each episode's task from a markerless snapshot

Restores per-episode scene rebuild alongside episode recording, which the
previous commit had to refuse: recording and a live "zed" perception both want
the same cameras and a ZED admits one owner.

The refusal gave up less than it appeared to. Live "zed" perception IS the
marker pipeline -- _live_records loads a marker registry and a dictionary name
and calls capture_frames -- and the 20mm ArUco markers are not resolvable at
this camera distance: 1 of ~7 detected on 30264679 and 0 on 32294776. So the
capability being sacrificed could not see these dominoes anyway. This replaces
it with the thing markerless actually offers.

The trick is that a snapshot opens no cameras. It is a second, short take on
the recorder's already-open session, taken between episodes while no episode
take is running -- so there is no second owner and nothing to arbitrate. A test
pins the sequencing, and the stub session now refuses a concurrent take the way
the real one does, so that test can fail.

It plugs in without touching the reset flow. RealRobot.reset_env homes the arm,
blocks until a human confirms the scene is arranged, and only THEN calls
perception.observe() -- which is exactly when a snapshot should be taken. So
MarkerlessSnapshotPerception duck-types the perception protocol and is injected
through make_real_robot(perception=...); open() and close() are no-ops, because
owning cameras here is the collision being avoided. attach_real_robot builds the
recorder before the robot so the session exists to hand over.

run_stages reads config.boxes and NOT config.boxes_json, so the runner resolves
the file itself. Getting that wrong would not fail -- it would quietly fall back
to the drag window on every episode, which is the difference between unattended
and not. A contract test pins it, along with the MarkerlessCapture fields and
run_stages' signature, and asserts run_stages' source mentions config.boxes and
not config.boxes_json.

z_mode defaults to "contact" here, opposite to the cascade default: a rebuild
looks at a scene a human has just arranged upright on the table, where z from
the table is right and better constrained. "free" is for dominoes at rest on
each other. table_z is passed rather than measured per capture, because the
twin's base->world transplant and the fit have to agree on where the table is.

Snapshot take directories carry a counter, not just a timestamp: start_take
makes the directory with exist_ok, so two snapshots in the same second would
write into one and the second would inherit the first's frames. Found by a test
that took two snapshots in a row.

Snapshots are tracked separately from episode takes. ``takes`` is what the fit
consumes; a snapshot is an input to a task, not a record of an execution.

10 tests, with the pipeline and scene loader injected so none of it needs a GPU,
a camera, or the submodule. The two contract tests were run against the bumped
submodule (b45ac97) via PYTHONPATH, since the installed babyrobot resolves to
the main checkout's older working tree. Full suite 1519 passed, mypy clean,
pylint 10.00/10.

* style: docformatter and isort over the new real-robot modules

CI runs docformatter 1.4 and isort as their own required checks, and I had run
only yapf and pylint over these files. docformatter rewraps the summary lines
and one-line docstrings its own way, and isort wanted the two new
pybullet_helpers imports reordered in the executor's test.

Produced by run_autoformat.sh's tools in its order (yapf, then docformatter,
then isort), so the three agree rather than each undoing the last. No wording
or behaviour changed. Full suite 1519 passed, mypy clean, pylint 10.00/10.
* sysID: score the friction fit against a markerless pose track

Step 3, and the point of the previous two. Under open-loop execution nothing
corrects the twin, so every recorded state is the twin's own PyBullet
simulation -- a per-step SSE over them recovers the twin's friction by
construction, which is exactly why the Stage-6 run could not learn one. The
track the cameras produce is the only real evidence in the episode.

code_sim_learning_rollout_score_observed_only (off by default) replaces the
per-step residuals with propagation intervals: when each domino started to
fall, relative to the first. The per-step loop is SKIPPED rather than added to
-- keeping it would let thousands of twin-against-twin terms outvote a handful
of real ones.

Intervals rather than poses, for two independently measured reasons. Absolute
base-frame position is 25-38 mm off while measured displacements are accurate
to ~1 mm, so scoring poses would score the extrinsics. And an interval is
invariant to however the track's clock is offset from the robot's -- which is
what lets alignment be a detected event rather than a clock reading, and is
why 3.2 of the plan needed no code at all. A test asserts that invariance.

Onsets are confirmed and then backdated because two spurious-fall mechanisms
have been measured on real takes and a naive threshold fires on both: gripper
occlusion produced a confident 29 deg topple 15 frames early, and orientation
drift wandered an untouched domino from 4.4 to 12.9 deg, across the 10 deg the
twin calls toppled. Neither reaches an unambiguous fall, so a fall is believed
only past 45 deg held for 3 samples -- the shape cascade_certificate._topple_
onset already uses -- and the onset is then walked back from there. Both traces
are tests, including the two combined, where the artifact must not drag the
true onset 15 frames early.

Notably NOT a rate or jump gate: those were measured upstream and rejected,
because during a real cascade the other dominoes translate 22-36 mm/frame,
overlapping the 48-67 mm of the artifacts. Speed cannot separate them;
visibility can, and the pipeline gates on it upstream.

Track ids are MATCHED to objects, not assumed. The ids are the order the
initialization boxes were drawn, which nothing makes agree with the env's
numbering. Every twin-point/track-point pairing is treated as a candidate
calibration offset and the one bringing the most dominoes within 40 mm wins.
Centroid cancellation was tried first and is wrong: one bad detection drags the
centroid and then NO pair matches, which a test pins.

A cascade that stalls on one side is penalised at the track's full duration
rather than skipped -- skipping would make a friction that stops the cascade
early look BETTER than one that reproduces it, by having fewer terms.

3.1: code_sim_learning_rollout_scope_types narrows the scored scope, empty by
default so the fidelity report is untouched. That report deliberately scores
"everything that moves"; identifying ONE parameter is a different question,
where the commanded arm reproduces at every candidate and can only dilute --
and with it in scope nothing ever rests, so rest-point segmentation can never
cut. _moving_feature_scope is now module-level (it captured nothing from its
closure) so the narrowing can be tested.

Flag on with no track: one WARNING and per-step scoring. Scoring zero residuals
would make every theta equally good and hand back the prior centre with a
confident-looking identifiability report.

* real robot: post-process each take into a track, and wait for it at fit time

The bridge between recording and scoring. Step 2 wrote .svo takes and Step 3
reads tracks; nothing turned one into the other, so the track path had to be
set by hand and could only ever name one episode.

real_robot_process_takes launches the markerless pipeline over each take as it
closes, detached, and joins the outstanding jobs when the run ends. Detached
because the pipeline runs about 3x the length of the take: inline it would
serialise post-processing into the episode loop and undo open-loop batching.
Launched per take rather than in one batch at the end because it parallelises
across takes at ~2 GB of a 24 GB card, so the work overlaps the next episode's
human scene reset instead of accumulating.

A manifest names each episode's take, its track, and whether the take was
usable. Rewritten in full as every take closes, so a run killed halfway leaves
a valid document rather than a truncated one. An unusable take is recorded and
marked, and deliberately NOT processed: a track fitted to a recording that lost
a camera mid-episode is a well-formed track of the wrong thing.

**The fit waits for the tracks the manifest promised**, up to
code_sim_learning_track_wait_s (15 min). This is the part that makes the flag
mean what it says. The manifest is written synchronously so it is always there,
but the tracks it points at are minutes behind, and the online loop fits as
soon as an episode ends -- so without the wait the fit would find nothing, warn,
and fall back to per-step scoring, silently reinstating the defect all of this
exists to remove. The distinction is that open-loop execution exists to stop
the ROBOT waiting on perception; the learner has no such excuse, because the
track is its data. The wait expires rather than hangs, and says the fit saw
less evidence than the run recorded.

Tracks are cached per path: a sweep evaluates the objective dozens of times,
and without it each candidate friction would re-parse a multi-megabyte JSON and
re-enter the wait.

z-mode is "contact" for episode tracks as well as scene captures. The scored
quantity is when each domino STARTS to fall, and at that moment it is still
standing on the table, so the mode that constrains z there is the right one.

A contract test pins run_markerless.sh's positional interface and the env vars
passed to it -- a shell contract with no type checker behind it, where a rename
would otherwise surface as a background job that fails silently and a track
that never appears.

* real robot: draw the prompt boxes once per run, and log each pipeline job

Two things that stood between the post-processing path and actually being
runnable.

**Boxes, once per run rather than once per take.** Stage 2 needs one box per
domino and is human-driven, which is the last thing gating unattended
operation. real_robot_pick_boxes_at_start takes a 5-frame snapshot when the
cameras open -- before any episode, while a human is still at the bench --
runs stages 1-2 with the drag window, and hands the boxes to the processor for
every take afterwards. Previously the only option was producing a boxes.json
out of band beforehand, which is a separate manual step nobody would remember.

This is valid because a fixed-plan replay trains and tests on ONE arrangement,
so boxes drawn on the scene as it stands are the right ones for every episode.
And it is self-checking rather than merely assumed: if the layout later shifts
far enough that a box no longer sits on its domino, stage 3's frame-0 identity
check aborts that take instead of tracking the wrong object. A failed draw is
not fatal -- the takes are still recorded for processing by hand.

Skipped entirely when real_robot_snapshot_boxes_json names an earlier run's
boxes, which is what makes a repeat run unattended.

**A log per job.** The pipeline runs detached, so its output had nowhere to go
and went to DEVNULL: a failed stage was a missing track and no reason, noticed
minutes later when the fit found nothing. Each job now writes stdout and stderr
to <bundle>/pipeline.log, and the failure message names that path rather than
just an exit code.

The log handle is attached to the process object rather than closed at launch:
the child writes to it for minutes after launch returns, so letting it be
collected would close the descriptor out from under a running stage. It is
closed when the job is joined.

Four tests. The logging one spawns a real script that writes to stderr and
exits non-zero, rather than stubbing the launcher -- which would have tested
nothing about the redirection. Full suite 1559 passed, mypy clean, pylint
10.00/10.

* domino real: configure the fixed-plan run for open-loop, recorded execution

Points exp_domino_real at everything the last three commits built, so the
experiment exercises record -> post-process -> fit end to end with the
fixed-plan explorer skipping straight to a real cascade.

The changes and why each is forced:

* open_loop_episode on, observe_at_option_boundary off. Mutually exclusive and
  asserted at construction -- a boundary look has to happen BETWEEN two
  options, and batching the episode leaves no such moment.
* perception "zed" -> "scene_file". "zed" is the MARKER pipeline, whose 20mm
  tags do not resolve at this camera distance (1 of ~7 on one camera, 0 on the
  other), and it would also fight the recorder for the same cameras. The
  captured layout is what a fixed-plan replay wants anyway: the plan names
  specific objects and a rebuild could renumber them.
* human_reset off, for the same reason.
* record_episodes and process_takes on, with pick_boxes_at_start so the one
  human interaction happens at the start of the run.
* score_observed_only on. Without it the two flags above make the fit WORSE
  rather than better: under open-loop nothing corrects the twin, so the
  recorded states are the twin's own simulation and a per-step SSE over them
  recovers the twin's friction by construction. Zero real observations instead
  of six.
* scope_types ["domino"], dropping the commanded arm and the colour channels.
* track_path at the run manifest, and a 900s wait for tracks still being
  post-processed.

num_online_learning_cycles 2 -> 1 for the first integration run: with
human_reset off there is no prompt between episodes, so a second cycle would
start on whatever the first left behind. Raise it once the path works, at
which point real_robot_snapshot_rebuild is what restores the between-episode
prompt without reopening the camera conflict.

Drops code_sim_learning_num_mcmc_steps: it does nothing for the rollout sysID.
The emcee branch was removed in 2026-07 and the flag is now read only by
fitting.py, so its comment here claimed an effect it has not had for a month.

* cogman: a plan that runs out has ENDED, not failed

run_20260817_160904 simulated all six of its options and shipped none of them.
The arm could not move at all under open-loop, in any configuration.

A fixed plan ends by raising OptionExecutionFailure("Option plan exhausted!"),
and that type is in the exploration loop's exceptions_to_break_on. The
end-of-episode verdict added for open-loop treated every break_on exception as
"this episode did not run to completion" -- so the normal terminus of every
fixed-plan episode was indistinguishable from an abort, and an executor that
defers its motion to the end of a completed episode discarded all of it:

    Option plan exhausted after 6 options.
    [CogMan] Finishing episode.
    WARNING: real robot: dropping 6 buffered option(s) unshipped -- the
             episode did not run to completion

The root cause is that one exception type carries two opposite meanings. So the
normal terminus is flagged where it is raised -- info={"plan_exhausted": True},
at both sites -- and only an unflagged break_on exception marks the episode
incomplete. Structural rather than matching on the message.

Why it took a hardware run to surface: with per-boundary shipping the motion
reached the arm DURING the episode, so the end-of-episode verdict decided
nothing. Open-loop is the first mode where that flag decides whether anything
moves, and no test covered a plan ending by exhaustion -- only by exception or
by step limit. The regression test does, and it reproduces the run: reverted,
it fails [False] == [True].

Also quiets zerorpc, the transport the arm is driven over. It logs a line per
channel at DEBUG, which at loglevel=DEBUG buries a hardware run's own output in
"--> new channel <uuid>". WARNING still surfaces a transport that is failing.

* real robot: fix the boxes handed to stage 2, and record only the motion

Three things the first hardware runs found.

Stage 2 died on int('id'). init_boxes.py WRITES records -- "id", "box",
"label" under a "boxes" key -- but the BOXES env it READS expects a bare list
of four-number lists. The records were handed over unchanged, so stage 2
iterated a dict and crashed minutes into run_20260817_162250, after the arm had
already executed the whole episode. The coordinates are unwrapped now, in list
order, which is the id order the writer enumerates. Verified against the
boxes.json that failed; both shapes are tested.

The take recorded the twin simulating. Under open-loop the arm does nothing
between the reset and the ship, so a take opened at the reset captures a static
scene -- 105 s of a 258 s take on run_20260817_165815. Trimming cannot recover
it: --trim-motion keeps everything between the first and last movement, and the
arm homing at the reset opens that window, leaving the still period in the
middle. The take is opened at the ship instead. Measured on the next run: 0.6 s
of dead air against 289 s of motion.

Per-boundary shipping still records from the reset, because there the motion is
spread through the episode. One existing test changed meaning rather than
behaviour and was rewritten to match: an aborted open-loop episode now leaves
nothing recording because it never STARTED a take, where before the stop was
what closed it. Same invariant; a companion test keeps the old assertion for
the per-boundary path.

The camera was whichever came first. real_robot_track_camera picks it, and
30264679 is the default: markerless is single-camera and the two are not
interchangeable -- on hand-measured ground truth this one is 6x better on
orientation (1.03 deg median against 6.29), which is what the topple onsets are
read off. Naming a camera the session does not record now raises up front
rather than failing per-episode with a missing file.

Also wires TRIM=1 (BabyRobotPredicator #78) so stage 1 drops the still lead-in
and tail. Worth having even though the take is now tight, and free: both of the
scan's failure modes keep frames rather than lose them. A driver that predates
the flag ignores it, so a contract test says which of the two is in front of us
instead of letting a silent no-op look like a working one.

Full suite 1572 passed, mypy clean over 539 files, pylint 10.00/10.

* domino real: point the experiment at the new scene, and bump the submodule

Submodule b45ac97 -> 603e4d4, two PRs:

  #77 stops stage 4 deadlocking on fork, defaults -j 16, and makes NEURAL the
      default depth model. The deadlock is in the exact path the background
      post-processing drives, so this is the one that matters. NEURAL comes
      along with it -- predicators cannot select a depth mode, because
      run_markerless.sh forwards nothing to stage 1 for it, so the mode is
      whatever stage 1 defaults to.
  #78 adds --trim-motion, which the previous commit wires up.

The experiment config follows the scene the bench is actually set up for:
domino_row_20260817.json with four dominoes, capture id 3 as the green start
and id 0 as the purple target. envs/all.yaml's 6 / 5 are domino_straight.json's
ids and appear nowhere in this scene -- left in place the task has no target at
all and _task_from_perceived asserts on it.

The fixed plan follows the same scene. domino_straight_bridge2_scene_roles.txt
picks domino_4 and domino_5 and pushes domino_1, none of which mean the same
thing here: this scene has only domino_0..3, its movables are domino_1 and
domino_2, and its start is domino_3. The sketches are checked in beside the
config that names them, so the config does not reference a file that only
exists on one bench.

* tests: never let the executor suite write into the repository

The suite clobbered a live run's manifest. EpisodeRecorder's default track
directory is the RELATIVE "logs/zed_tracks", and it rewrites tracks.json there
whenever a take closes, so a test that constructs a recorder without passing
track_dir writes into the repo -- and, if a run is in flight, over its manifest.

That is not hypothetical. run_20260817_171402 recorded its episode, stage 4
produced a good track (60455 records over 15695 frames), and the fit then
reported "episode 1 still has no track at logs/zed_tracks/take_20260817_174618
_train0_ep001/dominoes_traj.json" and fell back to per-step scoring. The take_
prefix is _StubSession.start_take's return value, the timestamp is when the
suite ran, and the serial is the camera the run was not even using. The
experiment lost its evidence to its own test suite.

Isolating the working directory fixes every present and future test at once,
where passing track_dir at each call site fixes only the ones someone
remembers. Verified by checksumming the restored manifest across a full run of
both affected suites: unchanged.

* sysid: dump the fit data when it arrives, not only when a fit runs

_persist_fit_trajectories sat inside the physical sysID fit, so the branch that
never ran was the branch whose data mattered. A cycle where the agent DECLINES
to fit is exactly the one worth a post-mortem, and it left nothing behind.

run_20260817_171402 is the cost. Its sweep reported one identical SSE
(5.418e+06) for every value of five different physical parameters, against a
baseline of 39.37 with no override applied -- a five-order-of-magnitude
discontinuity that appears the moment an override is applied at all, and does
not vary with the value. Three of its five segments were already pinned at RMS
601, a saturation value rather than a measurement. The agent read that as "this
data cannot constrain it" and declined, which was the right call on the evidence
in front of it. Diagnosing WHY needs the trajectories, and they died with the
process.

So the dump now hangs off _learn_simulator, where a cycle's data arrives,
whether or not anything is fitted afterwards. The label in the filename says
which moment produced it: "recorded" for the data as it arrived, "fitted" for
the existing post-fit dump, whose payload's identified params only mean
something there.

The test asserts the wiring as well as the function. Written the obvious way it
passed with the call deleted -- it called _persist_fit_trajectories directly,
which is a test of the dump and not of the thing that was broken. Calling
_learn_simulator for real needs a whole synthesis session, so the wiring is
pinned on its source instead; reverted, the test fails.

Full suite 1576 passed, mypy clean, pylint 10.00/10.

* real robot: stop reading a carried domino as a release

Every Pick shipped close, open, close, and the hand visibly opened around the
domino it had just taken.

Grasp commands closed_fingers - 0.01 = 0.00000, deliberately past the block.
The fingers STALL on it at 0.00658 -- #131 gave them a finite motor force so
they rest at the faces instead of closing through -- and the carry phases that
follow nudge from the ACHIEVED width, commanding 0.00658 - 0.001 = 0.00558.
The splitter only ever sees commands, never achieved positions, so it compares
that against the grasp COMMAND of 0.00000: a 5.58mm rise, which cleared the
5mm release epsilon by 0.58mm and became a spurious "open", followed by a
re-"close" on the next step.

Pre-existing; #131 exposed it by changing where the fingers come to rest. NOT
caused by open-loop batching: the gripper dedup is session-wide state applied
per segment, and a test added earlier proves the per-chunk segments are
identical batched or shipped one at a time.

This raises the epsilon to 0.008, which is a FITTED constant and is commented
as one. A carried object measures 0.00558 on this bench and a genuine release
measures 0.0122, so 0.008 is simply a value between them.

It cannot be derived, and three attempts at a principled fix are why:

* Requiring the release to leave the closed band broke
  test_split_actions_ignores_wobble_while_closing, which encodes a real
  release at 0.0122 against a closed of 0.02 -- inside the band.
* Clamping the carry nudge to the tightest command so far was inert: the
  rebound crosses the Grasp -> carry phase boundary, and Grasp is a
  CHANGE_FINGERS phase that never runs that code.
* Seeding that clamp from Grasp's command fixed the symptom but pinned the
  command at 0.00000 forever, so the fingers kept squeezing (achieved drifted
  0.00485 -> 0.00447). That undoes what #131 built and broke
  test_oracle_process_planning_solves_bridge_task.

The two cases are indistinguishable from the command stream -- both are
"widen after a grasp", differing only in magnitude. The real fix is to stop
inferring intent and carry the skill's own finger_status through on
Action.extra_info (unused today, and documented for exactly this), leaving
this constant to guard only actions that arrive without a stamp.

Three tests pin what the constant was fitted to, so a retune of the grasp
depth or the finger force names the two numbers to re-measure. Verified by
rolling a Pick out in the twin: gripper commands go ['open','close','open'] ->
['open','close'], with the commanded widths unchanged.
…137)

* sysID: match the track to the twin in one frame, and once per episode

Two defects between the pose track and the objects it is supposed to name.
Together they meant NOTHING was ever matched, on either hardware run.

The frame is the one that mattered. The markerless pipeline emits poses in
the ROBOT BASE frame; a twin state is in the env's world frame, and for this
env the two differ by a quarter turn about z plus (0.75, 0.72) -- exactly
pybullet_domino.real_geometry.base_to_world_transform, which the env applies
when it builds the task and nothing applied to the track. match_ids_by_position
votes over candidate translations, which is what absorbs the 25-38 mm camera
calibration error, but a rotation is not a translation: on run_20260818_092302
every pair sat 144-307 mm apart against a 40 mm tolerance.

Fitting a rigid transform to that run's own data recovers 92.24 deg and
(0.748, 0.718) against the constants' 90 deg and (0.75, 0.72), with per-domino
residuals of 2.7-12.9 mm. So the transform is measured, not assumed. It is
applied on the way out of the config-aware loader, so every track the objective
sees is already in the env's frame, and it is IDENTITY by default: an env whose
track already shares the twin's frame, and every test that builds both sides in
one frame, must be untouched.

This is what produced yesterday's meaningless numbers, and the arithmetic is
exact. When a cascade propagates in one stream but not the other,
_interval_residual_terms substitutes the track's own span, deliberately large.
run_20260817_171402's track is 268.768 s and summary_weight is 5, so one
penalised term is sqrt(5) * 268.768 = 600.98 -- the RMS 601 that appeared in
three of five segments -- and fifteen of them are 5.418e+06, the SSE that came
back IDENTICAL for every value of five different physical parameters. It was
identical because the penalty is the track's duration and does not depend on
theta at all. The earlier guess, that an override dropped the rollout into a
shared failure regime, was wrong: the residual was never a function of the
parameter being swept. Both runs' agents declined to declare on that, which was
the right read of the evidence they were shown.

The second defect is scope. match_ids_by_position ran per scored trajectory
against the track's FIRST frame, and the two only agree at the episode's start:
rest-point segmentation splits one episode into several trajectories, and this
plan picks and places two dominoes before the push, so a later segment begins
with them ~200 mm from where frame 0 saw them. _episode_id_maps now matches once
per episode, anchored there. Trajectories that pair one-to-one with tracks are
their own episodes and still anchor on themselves.

On the run that failed, both fixes take matching from 1-2 of 4 dominoes to 4/4
on every segment, and from zero usable intervals to three: 0.217, 0.084 and
0.034 s. The leftover position error is 14-31 mm, inside the calibration budget
the plan documented, which is the independent check that the transform is right
rather than merely fitted.

One test note. The obvious negative assertion -- that raw base-frame positions
match NOTHING -- is false and was corrected: the winning offset is drawn from a
candidate pairing, so that pair always matches itself. What a rotated frame
costs is every other domino, and that is what the test now asserts.

* sysID: a track is ready when it PARSES, not when the path exists

The pipeline writes a track in one pass and a dense one is tens of megabytes,
so between the path appearing and the last byte landing there is a window in
which the file is real, growing, and not valid JSON. The wait tested
os.path.exists, so it fell straight into that window: on run_20260818_092302
the fit logged "all episode tracks are ready" and then failed to parse at
"Expecting ',' delimiter: line 427907 column 20 (char 11997567)" of a 28 MB
document. Announcing readiness and then finding nothing usable is the worst of
the available outcomes, because it looks like success in the log.

track_is_complete parses instead. _settled puts a size check in front of it so
the wait loop does not re-read the whole document every two seconds -- a file
whose length changed since the last look is still growing and needs no parse to
rule out -- but the parse is what decides, since a writer that stalled would
hold its size steady while remaining truncated.

The second failure was next to it and is arguably worse. load_tracks called
load_track unguarded once the wait returned, so a truncated file raised out of
the function, and _load_scored_track catches at the granularity of the WHOLE
manifest. One half-written track therefore discarded the finished tracks either
side of it and sent every episode to per-step scoring, which under open-loop
scores the twin against itself. It is now skipped with a warning, exactly as an
absent one already was.

The give-up message changed with it: "still has no track" was accurate when
absence was the only way to fail, and now has to cover "present but still being
written" as well. The existing test that pinned that wording is updated.

Three tests, all of which fail if track_is_complete is reverted to
os.path.exists: a truncated file is not complete though it does exist, the wait
runs to its deadline rather than declaring victory on it, and a finished
episode survives a truncated neighbour in the same manifest.

Full suite 1580 passed before this change; rerunning. mypy clean, pylint
10.00/10.

* sysID: anchor the id matching on the cascade, not on the episode

Matching is positional, so the two snapshots being compared have to be of the
same MOMENT. The previous commit anchored the twin side on the episode's first
state, which is right only if the recording covers the whole episode. It does
today, and will not shortly: recording just the Push is being prepared as a
post-processing speedup, and such a take's first frame is AFTER the plan has
rearranged the scene.

Measured on run_20260818_092302, by taking the real track's arrangement just
before the cascade as a synthetic frame 0:

  whole take + episode-start anchor : 4/4 dominoes matched
  push only  + episode-start anchor : 2/4
  push only  + pre-push anchor      : 4/4

The plan relocates domino_1 and domino_2 before the push, so they sit ~150 mm
from where the episode began. Note the failure is partial rather than clean,
and a partial positional match is the dangerous kind: the offset is voted for
over candidate pairings, so a shifted offset can match a subset exactly and
assign every one of them to its neighbour.

So both sides now anchor on the settled arrangement immediately BEFORE the
cascade -- the twin's last state before any fall angle confirms, and the track's
last frame before its first onset. That moment is identifiable in either stream
whichever window was recorded, and it is the arrangement the propagation
intervals are measured on. Neither "the episode's start" nor "each segment's
start" is right in general; each was right only for one recording window.

ObservationTrack carries pre_cascade_xy alongside first_xy, filled by a second
pass over the parsed document once the onsets are known -- the onsets are not
available during the first pass, and re-walking what is already in memory is
cheap next to carrying every frame's positions. first_xy remains the fallback
for a track in which nothing ever falls. The frame transform moves both sets,
or the anchor would sit in a different frame from its own fallback.

The twin side reads roll by object NAME, so it needs no id mapping and there is
no circularity. match_ids_by_xy is split out of match_ids_by_position because
the twin side is no longer a single State.

Both recording windows now give the identity mapping on all four segments of
the real run. The test builds an episode that places a domino 600 mm from where
it started and a take that only ever sees the placed layout; with the anchor
reverted to the episode's first state it fails.
Saving learned NSRTs and GNN weights intermittently dies with
`TypeError: cannot pickle '_abc._abc_data' object` -- the C-level cache behind
an abstract base class. It has blocked three PRs on three different shards this
week and it predates all of them: the same nine-test cluster reproduces on
unmodified master.

WHAT IS ESTABLISHED

Two call sites, both reached from the CI tracebacks: nsrt_learning_approach.py
:114 (`pkl.dump(self._nsrts, f)`) and gnn_approach.py:207 (`pkl.dump(info, f)`).
Every failure seen so far funnels through one of them.

On CI it is DETERMINISTIC for a given test set: shard 8 failed twice on the same
single test, and shard 6 twice on the same three, across re-runs of untouched
jobs. Which shard is hit moves with pytest-split's packing, which is why adding
tests to an unrelated PR appears to "cause" it.

Locally it is stochastic -- 3 of 12 identical runs, with code, test order and
PYTHONHASHSEED all fixed. The likely difference is that CI containers are
uniform while a developer machine is not.

WHAT IS NOT ESTABLISHED

The root cause. An `_abc_data` holds WEAK references, so whether dill trips over
one plausibly turns on collection timing -- the one thing that still varies with
everything else pinned. That is the hypothesis this mitigation is aimed at, and
it is a hypothesis: it is stated here rather than dressed up as a diagnosis.

If the failures stop, that is also the evidence for it. If they do not, it rules
the hypothesis out, which is worth knowing too.

Ruled out along the way, none of them the cause: pytest-randomly (not installed
-- `-p no:randomly` is a silent no-op here), execution order (progress lines
match character-for-character between a red and a green run), hash randomisation
(PYTHONHASHSEED=0 is exported), and any single polluting test (a bisection
appeared to find one, then the control showed removing its neighbours worked
equally well -- that bisection was invalid, having used single runs to measure a
25% event).

WHY IT IS SHAPED THIS WAY

Serialising to bytes before writing, rather than retrying into the handle: a
dump that raises part-way has already written a prefix, and appending a retry to
that leaves a corrupt file which only fails at LOAD time -- much worse than the
error being fixed. A test pins the file being empty after a persistent failure.

Any TypeError is retried, not just the `_abc_data` one. Matching on the message
would break silently when it is reworded, and an object that is genuinely
unpicklable fails the second time too and raises exactly as before -- so the
broad catch costs one wasted attempt and hides nothing.

Two tests, both mutation-checked: removing the retry fails the first and not the
second, and dumping straight into the handle fails both.

Full local gates: mypy clean, pylint 10.00/10, yapf/docformatter/isort applied.
The previously failing shard passes locally, though a single local run is weak
evidence at a 25% rate -- CI is the real test of this, and is why it is worth
landing to find out.
…n overlay nobody reads (#138)

* real robot: give stage 4 the cores it has, and stop rendering an overlay nobody reads

run_20260818_092302 recorded a good episode and then could not use it. Post-
processing took 1008 s against a 900 s fit deadline, so the fit skipped the take
it had just paid a robot to produce and fell back to per-step scoring -- the
twin-against-twin case the whole open-loop design exists to avoid. It missed by
108 s.

The pipeline's own estimate says "about 3x the length of its take". Measured
from the stage artifacts' timestamps it was 7.3x, and the discrepancy is not
noise: the 3x figure was calibrated at 30 fps, and these takes record at 60 to
resolve the propagation intervals. Cost scales with FRAMES, so doubling the
frame rate doubles the multiple. 138 s of video, 7773 frames, 1008 s to a track:

  stage 1 replay -> frames.mp4     116 s
  stage 2 boxes (given)              0.5
  crop -> frames_crop.mp4           40
  stage 3 SAM-2 propagation        326
  masks_overlay.mp4 render         163     <- nothing reads this
  stage 4 fit + emit               364
  stage 5 poses overlay             60     (after the track; free)

Two of those are addressed here.

JOBS. Stage 4 fans out and ran 16 workers on a 32-core machine, which is the
driver's default rather than a decision about this box. The arithmetic says it
was cleanly core-limited -- 486 frames per worker over 364 s is 16.0 cores busy
for the entire stage -- so it is the rare case where more workers really do
translate. real_robot_track_jobs=0 keeps the driver's own choice; the domino
config asks for 30, sized for this machine and deliberately not 32, since the
pipeline runs in the background while the next episode is driving the robot.

TRACK_VIZ. masks_overlay.mp4 is a debugging aid nothing downstream opens, and it
is rendered BEFORE stage 4 -- so its 163 s is not tacked onto the end, it is
paid straight out of time-to-track. Default off for automated runs, and easy to
put back: that same run turned up a real id-matching problem, and the overlay is
how id swaps get spotted. Needs BabyRobotPredicator#79; a driver that predates
it renders the overlay as it always did rather than failing, which is what lets
this land first.

0 means "leave the driver alone" for JOBS, and viz=True means saying nothing at
all rather than passing TRACK_VIZ=1 -- an empty or contradictory env var is
worse than an absent one when the far side is a shell. Both are asserted, and
both assertions were checked against a mutant: dropping either guard fails its
test.

Expected effect, not yet measured on hardware: ~165 s from JOBS and 163 s from
the overlay, so roughly 1008 s -> 11 min. That still exceeds no deadline by
itself -- code_sim_learning_track_wait_s is untouched here and is the next
thing to fix, along with reading a track only once it is completely written.

* submodule: pick up TRACK_VIZ, so the overlay switch actually does something

BabyRobotPredicator 603e4d4 -> ba4ee81 (#79). The previous commit already sets
TRACK_VIZ=0 on every launch; against a driver without it that was simply
ignored, which is what let the two land in either order. With this bump the
163 s masks_overlay.mp4 render is genuinely skipped and real_robot_track_viz
stops being a no-op.
* real robot: open the take in front of the push, not the whole batch

Only the cascade is scored. On run_20260818_092302 the first onset landed 107 s
into a 131 s track, so roughly 80% of the video was the pick-and-place that
arranges the row -- none of it evidence, all of it post-processing, which scales
with frames rather than seconds.

real_robot_record_from_option names the option the take opens in front of.
_ship_episode sends the prologue as one unrecorded request, opens the take, then
sends the rest. That costs one controller round trip and NOT a planner call: the
twin has already simulated every option by the time _ship_episode runs, so this
does not give back what open-loop batching bought. Splitting is safe for the
same reason per-boundary shipping was -- _split_actions restarts its gripper
tracking per call and RealRobot dedups session-wide -- and both requests still
go out with observe=False, so the write-only side-effect argument that makes the
twin trajectory bit-identical is untouched.

The arm coming to rest at the boundary is a small gain of its own: the free-run
is anchored at the last rest state before the push, and now there really is one.

Empty, or a name the episode never runs, records everything and warns. Too much
video is slow; too little is an episode whose first topple happened off camera,
and the first onset is what every interval is measured against.

STACKED ON #137. This moves the track's first frame from the episode's initial
arrangement to the post-place one, and the id matching anchors on a position
set. Against #137 as originally written -- anchored on the episode's first state
-- that was fatal: the two placed dominoes sit ~150-200 mm from the anchor and
drop out, taking the match from 4/4 to 2/4. 98e24cd moved both sides to the
settled arrangement immediately BEFORE the cascade, which is independent of when
recording starts, and push-only measures 4/4 again. This commit is only correct
with that fix underneath it.

An earlier version of this branch claimed recording from the push FIXES the id
matching. That was wrong. The dominant cause was the base-frame/world-frame
rotation #137 identified -- 144-307 mm against a 40 mm tolerance -- and starting
the take later does not touch it.

No lead-in, as a deliberate first cut. Two known hazards ride on that, neither
fixed here and both likelier on a short take: load_track takes each domino's
first OBSERVED centre, so a start domino occluded by the gripper for the whole
pre-cascade window contributes no position and drops out of the match; and the
offset vote's tie-break does not settle exact aliasing, so an evenly spaced row
missing one domino can match completely and be shifted by one. If either bites,
splitting one option earlier is the fix.

Tests: five, plus three mutants -- no prologue split, recording opened before
the prologue, and the unknown-option fallback recording nothing. The third
initially PASSED, because asserting one shipment of three chunks cannot tell
"everything recorded" from "everything shipped, then the take opened"; the
ordering probe was added to pin it, and it fails under that mutant now.

* real robot: draw the prompt boxes where the take opens, not at run start

The boxes are SAM-2 prompts applied to the take's FIRST FRAME, so they have to
describe the arrangement that frame shows. While the take began at the reset
those were the same thing and the draw sat at run start. Recording from a later
option makes them different, and nothing detected it: stage 2 does not report an
empty box, it fits a mask to whatever is inside the one it was given.

Observed on run_20260818_140211. boxes.json was written at 14:02:37, before any
motion; the take opened at 14:05:37, after the prologue had picked and placed
two dominoes. Two of the four prompts pointed at bare table. A human noticed it
in the overlay, which is not a detection mechanism.

So the draw moves to the recording boundary: after the prologue ships, before
_start_recording. The arm is at rest and the row is in its final arrangement,
and it is also the only moment a human can draw boxes that match the take. Where
the take still opens at the reset -- no record_from_option -- the draw stays at
run start, which was already right there.

ensure_boxes is now idempotent. The boundary call site runs every episode and
the draw must not: a fixed-plan replay arranges the same row each time, and a
drag window opening per take is exactly what pick_boxes_at_start exists to
avoid.

This makes a real trade rather than removing a cost. The human is now needed
~50 s into the first episode instead of before it starts. The unattended
alternative is projecting the twin's geometry into image space, which needs no
human at all and is considerably more work.

Three tests, on the ordering rather than the fact of the draw -- asserting that
boxes were drawn cannot tell the right moment from the wrong one, which is the
whole defect. The probe records what had shipped and whether the take was open
at draw time, and asserts one shipment and no open take. Both mutations fail it:
drawing at run start regardless, and deleting the boundary call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants