Skip to content

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

Closed
amburger66 wants to merge 24 commits into
masterfrom
domino-experiments
Closed

Open-loop execution and continuous recording for the real domino friction fit#133
amburger66 wants to merge 24 commits into
masterfrom
domino-experiments

Conversation

@amburger66

Copy link
Copy Markdown

What this is

Steps 1 and 2 of the plan in claude_plans/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

3b41dd8 the plan document
7176430 grasp-offset correction and bridge plans (experiment artifacts)
7b81ce1 merge origin/master
df7d108 bump submodules/BabyRobotPredicator 396094eb45ac97 (eight PRs)
237bc61 Step 1 — open-loop episode execution
6f9bc15 Step 2a — record each episode to an SVO take
d2e3b41 Step 2b — rebuild each episode's task from a markerless snapshot

The branch also carries earlier config and experiment work that predates this series.

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.

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.
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.
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.
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.
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.
… less far

Two measured fixes to the same skill pair.

Pick commanded closed_fingers - 0.01, which with the domino env's 0.02
put the target 10 mm past closed. A 0.029 m domino blocks the fingers
well before that, so the position motor drove to the joint limit: 18 mm
of penetration, measured. A grasp here is a JOINT_FIXED weld created on
0.5 mm proximity, so the overshoot buys nothing once the fingers touch
-- and past that it only stores contact impulses that kick the domino
when the weld is released. The overshoot is now a SkillConfig field
(default 0.01, so nothing else changes) with a CFG override.

Place released the domino from an EE height of 0.58, about 4 cm above
where it rests, and the fall is what scattered it. Over 3 placements
each, mean error was 43 mm at 0.580, 15 mm at 0.568, 10 mm at 0.565.
The floor is the collision-aware Place goal rather than the physics:
below ~0.565 BiRRT cannot route the held domino to a goal that puts it
through the table and raises OptionExecutionFailure (0.560 fails).
0.568 keeps 8 mm of margin above that edge.

Only the release height is shown to improve placement; the overshoot
change removes a real defect but its effect on placement error was
within run-to-run noise.
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.
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.
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.
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.
real_robot_dry builds the RealRobot without a RobotInterface, so the arm
is never contacted and no polymetis server is needed, while the option
loop, the twin correction, the divergence logging and the learning all
run as they would live.

Paired with real_robot_perception "none" deliberately. Live cameras on a
motionless arm report a static scene, so every look would correct the
twin back to the initial layout and the learner would see nothing but
no-ops; blind keeps the twin evolving in sim, which is what exercising
the pipeline wants.
Brings the branch onto master through #116 (squash-merged from this
branch's own evaluator work) and #118 (the BabyRobotPredicator bump that
lands #63's measured table height). Merging rather than rebasing because
#116 arrived as a squash: replaying this branch's six pre-#116 commits
would mean hand-resolving conflicts against content master already has.

Conflicts, both taken deliberately:

- submodules/BabyRobotPredicator -> master's 396094e. The branch was
  pinned a commit behind, at d53b02b, which predates #63; the whole
  point of #118 was to stop a scene captured through the submodule's
  own scripts carrying the 4 mm z error back.

- exp_domino_real.yaml -> the branch's side wholesale. It is a superset
  of master's: same scene, execute and divergence tolerance, plus the
  friction mismatch, the MCMC steps, the dry-run rung and the
  agent_oracle_hybrid_sim block. The one master value not carried is
  real_robot_observation_dump_dir, which this branch repointed from
  logs/stage6_looks to logs/friction_0.1 on purpose.
real_robot_perception "none" on its own raises at executor construction:
the option-boundary look asks the robot to perceive, and it has no
cameras. Turning the look off is not enough either -- with human resets
on, reset_env perceives after the human confirms, so it fails at the
first episode boundary instead.

All three go together, and now say so in the file.
Swaps the exploration half for a constant so the run measures what it is
meant to: whether perception feeds the learner well enough to move the
friction belief. A planning explorer varies episode to episode and costs
minutes each; a fixed plan makes every episode comparable and puts any
failure on the loop rather than the planner.

The plan is the one the agent explorer found in run_20260807_091533
(12/12 at reward 0.95, the optimal score). Verified 8/8 in the BELIEF
simulator -- the env built with skip_process_dynamics=True, whose
PyBullet bodies really do carry domino_planning_friction 0.1 -- which is
the requirement: the agent has to believe the plan works. Checked
against the hand-written bridges on the same scene: solve_v2_bridge2 is
5/8 there and solve_v2 is 0/5 (Push IK).

Also flips agent_planner_use_base_simulator on. The agent's plan-testing
simulator is built with skip_process_dynamics=that flag, and only a
skip_process_dynamics=True env picks up domino_planning_friction -- so
left at its default the agent was testing plans against the TRUE
friction and its belief was never mismatched at all. Nothing for a sysID
to discover.

Live rung: the arm moves and the cameras look. A real cascade has to
happen to be observed, so neither half can be faked. Human resets are on
so each task is rebuilt from a look, which is the thing under test; Pick
and Push follow perceived poses, only Place's drop point is absolute.
#117 was closed rather than merged: its config flag was never actually
used, and it lowered the oracle Place drop height for every embodiment
on the strength of measurements taken only on the Panda. This branch had
been carrying that work locally ever since.

Reverted to master exactly:

  * _DOMINO_DROP_Z back to 0.58 from 0.568
  * SkillConfig.pick_close_overshoot dropped; Pick's close target is the
    literal closed_fingers_joint - 0.01 again
  * the skill_pick_close_overshoot setting dropped
  * _build_skill_config back to returning SkillConfig directly
  * the sampler test back to asserting the literal 0.58, which is
    correct again now the constant is 0.58

Nothing else was mixed into those files: the whole local delta on all
six was #117's, so restoring master's copies is the entire revert. The
#119 fixes they also contain (the Pick grasp box, the captured-scene
guard) are master's and stay.

Worth knowing before the next hardware run: 0.580 measured a 43 mm mean
placement error against 15 mm at 0.568, over 3 placements each. The
drop-height change is still wanted -- scoped to the Panda, the way the
Pick box is -- rather than abandoned.
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.
… roles

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.

bridge2/bridge3 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. bridge3 exists because 2 movables at 0.110 m pitch only reached the
target 3/8 at friction 0.1 -- low friction shortens topple reach.

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.

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.

can we put these plans in the docs subdir?

Comment thread plans/agent_best.txt

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.

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

Let's organize the claude_plan/plan files a bit, but otherwise they look good! Thanks!

@amburger66

Copy link
Copy Markdown
Author

Superseded by #134, which is branched cleanly off master. This branch's commit list included ~18 older commits whose content already reached master via the squash-merge of #119 — git could not see them as ancestors, so they showed up as new. #134 carries the same changes as six commits with no such noise.

@amburger66 amburger66 closed this Aug 17, 2026
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