Skip to content

Open-loop execution and continuous recording for the real domino friction fit - #134

Merged
amburger66 merged 7 commits into
masterfrom
domino-openloop-recording
Aug 17, 2026
Merged

Open-loop execution and continuous recording for the real domino friction fit#134
amburger66 merged 7 commits into
masterfrom
domino-openloop-recording

Conversation

@amburger66

Copy link
Copy Markdown

What this is

Steps 1 and 2 of the plan in docs/domino-openloop-continuous-perception.md: move real-robot execution from per-option shipping to one batch per episode, and record each episode for offline markerless pose estimation instead of taking six option-boundary looks.

Every new flag is off by default. With them off, behaviour is byte-for-byte what it was.

Why

The Stage-6 friction fit currently scores the simulator against itself. Of ~229 recorded states in the completed run, 6 were real camera looks; 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 correct read of that evidence. This series fixes the evidence rather than the decision.

Two facts shape the design:

  • The markerless pipeline runs at ~3.2x real time. No perception result can come back inside the episode that produced it, under any design. Execution must not depend on one.
  • The 20mm ArUco markers are not resolvable at this camera distance — 1 of ~7 detected on 30264679, 0 on 32294776. The live "zed" perception is the marker pipeline, so it cannot see these dominoes.

The commits

8b6adcf the plan document
d752617 grasp-offset correction and the bridge plan sketches
0d8e690 bump submodules/BabyRobotPredicator 396094eb45ac97 (eight PRs)
f007e39 Step 1 — open-loop episode execution
421ffcb Step 2a — record each episode to an SVO take
62f293c Step 2b — rebuild each episode's task from a markerless snapshot

Step 1 — open-loop execution

real_robot_open_loop_episode holds each completed option and ships the whole episode as one request once it has all been simulated, instead of shipping option-by-option while the arm idles through the next option's motion planning.

Why deferring is safe. 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. A test asserts the twin trajectory is bit-identical, 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 live inside after_step. after_episode(completed) closes that gap; BaseEnv.finish_execution is a no-op so cogman stays env-agnostic.

completed=False drops the buffer. 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.

Step 2 — recording, and the snapshot rebuild

real_robot_record_episodes wires ZedRecorderSession onto the executor lifecycle: open() once per run, a take per episode, close() at exit.

Recording stops in a finally; shipping does not. On an abnormal end these want opposite things: a partial plan must not reach the arm, but a take left open records until the disk fills. Failure handling is asymmetric for the same reason — start_take raises (an episode that cannot record burns hardware time and a human scene reset for nothing), stop_take never does (by then the arm has moved, and a recording problem must not destroy the run around it, so the take is logged and marked unusable).

Recording and live "zed" perception are refused together: both open the same cameras and a ZED admits one owner.

real_robot_snapshot_rebuild then restores per-episode task rebuild without reopening that conflict. 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. It plugs in at RealRobot.reset_env, which homes the arm, waits for the human, and only then calls perception.observe() — exactly when a snapshot should be taken.

Defaults come from the pipeline's own measurements: HD720 at 60 fps, not 30, because 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 will be scored on.

What this does NOT do yet

The learner still does not see real observations. The fit reads _fit_trajectories, which is the obs stream out of env.step. This series produces .svo takes and marks which are usable; nothing yet runs the markerless pipeline over them or hands the resulting pose track to the fit.

That is Step 3, and it is where the real poses reach the learner — through the objective, not the trajectory. Concretely still missing: running reconstruct_dominoes_markerless.py over each take, the scope fix (§3.1), event alignment (§3.2), the propagation-interval residual (§3.3), and code_sim_learning_rollout_score_observed_only (§3.7).

Running with real_robot_open_loop_episode on and Step 3 absent would make the fit worse, not better — zero real observations instead of six.

Verification

  • Full suite: 1519 passed, 0 failed. mypy clean, pylint 10.00/10, yapf clean.
  • 31 new tests, all driving stubs — no GPU, camera, or submodule needed.
  • Mutation-checked, because a test that cannot fail is worthless:
    • removing the deferral reds 4 open-loop tests;
    • the bit-identical test reds when the deferred path returns anything other than what it was given (its first version passed under mutation because the stub returned the same state it was fed — it was strengthened);
    • removing the finally reds the shipping-raises test;
    • ignoring meta["errors"] reds the unusable-take test.
  • Two contract tests pin the stubs against the real ZedRecorderSession and the markerless staging, run against b45ac97 via PYTHONPATH. They skip where the submodule is absent, matching the convention in test_real_robot_bridge.py.

No hardware validation. None of this has been run on the arm or the cameras.

Reviewer's attention

  • cogman.py gained four call sites, not the two the plan named. Reading the control flow, two raise paths leave the function without either — harmless for Step 1, but it would strand a recording under Step 2.
  • Two duck-typed env mocks in test_cogman.py gained the no-op, since cogman now calls finish_execution on the BaseEnv it is declared to take.
  • real_robot_snapshot_z_mode defaults to "contact", opposite the cascade default, because a rebuild looks at a scene just arranged upright on the table.
  • mypy.ini gains a pose_estimation.* stanza for the same reason babyrobot.* has one.
  • Fixed-plan sketches live in scripts/plan_sketches/, named for what distinguishes them (scene, bridge size, grasp offset or scene-derived roles) rather than for the run that produced them. exp_domino_real.yaml's fixed_plan_explorer_path follows.

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.
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.
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.
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.
…ation

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.
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.
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.
@amburger66 amburger66 self-assigned this Aug 17, 2026
@amburger66
amburger66 marked this pull request as ready for review August 17, 2026 15:26

@yichao-liang yichao-liang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@amburger66
amburger66 merged commit 7079d7d into master Aug 17, 2026
14 checks passed
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.

2 participants