zero2robot · Phase 5 · Practitionerch5.8-real_loop · real_loop.py

Chapter 5.8

The Real LoopTeleoperate, Record, Train, Deploy — on a Real Arm's Body (in sim)

By the end you can

  1. Fetch a REAL robot model reproducibly and free-tier — download the Menagerie `robotstudio_so101` MJCF + 18 STL meshes into a gitignored cache (integrity-checked: the two XMLs are sha-pinned, the binary meshes size-checked, and the final proof is that MuJoCo compiles the model), skip-if-present. No binaries in git (invariant
  2. Run the WHOLE LeRobot loop on the SO-101's real body in sim — drive a deterministic scripted-expert reach (shoulder_pan aimed at the box's azimuth + a fixed lower-to-the-table pose, commanded as position targets), RECORD it as a real STATE-only LeRobotDataset (the actual create/add_frame/save_episode/finalize API, v3.0), load that dataset BACK, TRAIN ch1.1's BC MLP on it (MSE in NORMALIZED action space so the big joints don't drown the tiny box-dependent pan signal), DEPLOY the clone (policy -> d.ctrl), and EVALUATE a success rate with ch1.6 rigor.
  3. Internalize the DIRECTION as the seed-robust claim — the recorded-then-cloned policy reproduces the scripted reach (success clearly above no-op / random), i.e. the loop closes end-to-end on the real arm's body. It is a MECHANISM/loop claim, not SOTA, and reported as a direction, not a %: MuJoCo contact + servo settling are bitwise on ONE CPU but not across arches (ch1.6).
  4. Feel the reality gap by naming what the twin CANNOT give you (the honest core, below) AND by breaking the loop the way the metal breaks it — `--break obs_swap` wires the DEPLOY observation's box_x/box_y opposite to the RECORDING (the ch0.4 lesson): training is spotless, loss is ~0, and the arm reaches the wrong way. A wiring bug no loss curve can see.

See it work

live · P2

The loop, deployed two ways

The whole LeRobot loop — drive → record → train → deploy → eval — run on the SO-101's real morphology. The clone curls in and lands on the box: a reach. The broken rollout uses the same box and the same trained weights — the only difference is the order the deploy script read box_x / box_y, and the tip mirrors to the wrong side.

  1. driveteleop the arm
  2. recordLeRobot dataset
  3. trainBC on joints
  4. deploypolicy → arm
  5. evaldid it reach?
SO-101 gripper-tip reach — top-down replaybox · x 0.286 m, y -0.021 m · reach tolerance 0.08 mtop-down · SO-101 workspace (x, y) · metres
60/60
toggle clone/broken · scrub the rollout · poster reads with JS off
Does the loop close? · success rate over 30 held-out eval episodes
cloned policy
100%
no-op baseline
3.3%
random baseline
0%

The recorded-then-cloned policy reproduces the reach on the arm's real body, clearly above doing nothing or flailing — the loop closes. A direction, not a headline %: this is the loop working, not manipulation solved.

Clone rollout, frame 60 of 60. Loop stage: eval. Gripper tip 0.076 m from the box — within the 0.08 metre reach tolerance. The recorded-then-cloned policy reproduces the reach on the arm's real body.

This is a reach in sim on the SO-101's real kinematics and the real LeRobot dataset format — the loop closing on the arm's body, not hardware fidelity. What the twin cannot give you is the reality gap: servo backlash, unmodeled friction, read-to-send latency, calibration drift. A clean 100% here does not paper over it. Those gaps stay reading — the graduation G1module, where you buy the arm. The broken rollout is the same lesson as ch0.4 (you can only imitate the obs you recorded) with a physical consequence. Real recorded rollout + rates from real_loop.py (seed 0, cpu); poster reads with JS off. Task: “Reach the box with the SO-101 gripper.”.

Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

The whole course, run once, on a real robot's body

Every method in this book ran against an environment we wrote. PushT, ALOHA-cube, the cartpole — small, clean, ours. That was the right call for learning a mechanism: when the sim is a hundred lines you can read, you always know exactly what the algorithm saw. But it left one honest question open, and it is the only question Phase 5 exists to answer: does any of this survive contact with a real robot?

This chapter runs the entire LeRobot loop — drive, record, train, deploy, evaluate — on the SO-101's real morphology: the same $150 arm the graduation bridge (G1) sends you to buy, loaded straight from google-deepmind/mujoco_menagerie. Six sts3215 position-servo joints, the manufacturer's link lengths, the manufacturer's joint limits, a bundled wrist camera. Not a simplified stand-in — the real body, in MuJoCo, free-tier, on your laptop. And the format and the control loop are byte-for-byte what you would run on the metal.

Open real_loop.py. Nine regions: setup, fetch, env, expert, record, model, train, deploy, report. The middle seven are the loop.

First, fetch the real robot — without committing a single mesh

real_loop.py#fetchsha256:897b8fa177…
# Reproducibly fetch the SO-101 model — Apache-2.0, from google-deepmind/mujoco_menagerie# (`robotstudio_so101`). We NEVER commit meshes (invariant #5): the MJCF + 18 STL assets download# once into a gitignored cache, integrity-checked, and skip on every run after. We pull through the# jsdelivr CDN (a GitHub mirror keyed by the same ref) rather than raw.githubusercontent — same# bytes, but built to serve many small files without the burst rate-limiting a from-scratch fetch# trips. The two XMLs we parse are sha-pinned (they define the robot); the binary meshes are# size-checked (a rate-limited HTML error page is a few hundred bytes — a real STL is tens of KB),# and the final proof of a good fetch is that MuJoCo compiles the model. The commit is UN-PINNED on# purpose (G1's doctrine: the upstream moves), so an author bumps --menagerie_ref without this file._RAW = "https://cdn.jsdelivr.net/gh/google-deepmind/mujoco_menagerie@{ref}/robotstudio_so101/{path}"_XML_SHA = {  # sha256 of the two MJCFs we depend on; a changed robot definition trips this    "so101.xml": "5ad49f2b45c083baac9ffe5d4d3213a5da7eac8039095bb2df177a697aae8308",    "scene_box.xml": "14c3826f23889587e63b88ef9a0e7d72318ffe13078fad8287c56dbdb9ba9ad8",}_ASSETS = [  # the 18 STL meshes so101.xml references (kept as a literal list — no directory crawl)    "waveshare_mounting_plate_so101_v2.stl", "sts3215_03a_v1.stl", "motor_holder_so101_base_v1.stl",    "wrist_roll_follower_so101_v1.stl", "moving_jaw_so101_v1.stl", "base_motor_holder_so101_v1.stl",    "upper_arm_so101_v1.stl", "wrist_roll_pitch_so101_v2.stl", "under_arm_so101_v1.stl",    "rotation_pitch_so101_v1.stl", "motor_holder_so101_wrist_v1.stl", "sts3215_03a_no_horn_v1.stl",    "base_so101_v2.stl", "moving_jaw_so101_gripper_v1.stl", "wrist_roll_follower_so101_camera_mount.stl",    "wrist_roll_follower_so101_gripper_part0_v1.stl", "moving_jaw_so101_gripper_part0_v1.stl",    "moving_jaw_so101_gripper_part1_v1.stl",]  def _download(path: str, dest: Path, ref: str) -> None:    """Fetch one file with a few retries (raw.githubusercontent rate-limits bursts)."""    url = _RAW.format(ref=ref, path=path)    for attempt in range(5):        try:            with urllib.request.urlopen(url, timeout=30) as resp:                dest.write_bytes(resp.read())            return        except Exception as err:  # noqa: BLE001 — network flake / rate-limit: back off, retry, then surface            if attempt == 4:                sys.exit(f"failed to fetch {url}: {err}")            time.sleep(1.5 * (attempt + 1))  # raw.githubusercontent throttles bursts; ease off  def fetch_so101(cache: Path, ref: str) -> Path:    """Download the SO-101 MJCF + meshes into `cache` (skip-if-present), then compile to prove it."""    scene = cache / "scene_box.xml"    assets = cache / "assets"    have_all = scene.is_file() and all((assets / a).is_file() for a in _ASSETS)    if not have_all:        assets.mkdir(parents=True, exist_ok=True)        for name, want in _XML_SHA.items():            _download(name, cache / name, ref)            got = hashlib.sha256((cache / name).read_bytes()).hexdigest()            if got != want:                sys.exit(f"{name} sha256 {got} != expected {want} (menagerie_ref={ref!r} moved the robot def)")        for a in _ASSETS:            _download(f"assets/{a}", assets / a, ref)            if (assets / a).stat().st_size < 10_000:  # an HTML error page, not a mesh                sys.exit(f"asset {a} is {(assets / a).stat().st_size} bytes — a corrupt/rate-limited download; re-run")    model = mujoco.MjModel.from_xml_path(str(scene))  # the real integrity check: does it compile?    assert (model.nu, model.nq) == (6, 13), f"unexpected SO-101 model: nu={model.nu} nq={model.nq}"    (cache / "manifest.json").write_text(json.dumps(  # a pointer, never the meshes themselves        {"ref": ref, "source": "google-deepmind/mujoco_menagerie/robotstudio_so101",         "xml_sha256": _XML_SHA, "assets": sorted(_ASSETS)}, indent=2, sort_keys=True) + "\n")    return scene  scene_path = fetch_so101(args.cache, args.menagerie_ref)print(f"SO-101 model: {scene_path} (cached; ref={args.menagerie_ref})")

The SO-101 model is an MJCF plus eighteen STL meshes, about 17 MB. Invariant #5 is absolute: no binaries in git. So we fetch it — once — into a gitignored cache, and skip the download on every run after. Reproducibility without a committed blob comes from three checks, cheapest first: the two XMLs we actually parse are sha-pinned (a changed robot definition trips an assertion, not a silent surprise); the binary meshes are size-checked (a rate-limited HTML error page is a few hundred bytes, a real mesh is tens of KB); and the final proof of a good fetch is that MuJoCo compiles the modelnu=6, nq=13 or we stop.

We pull through a CDN mirror keyed by a git ref, and that ref is un-pinned on purpose. This is the same doctrine G1 states for the LeRobot CLI: the upstream moves weekly, so we hold a template, not a frozen commit. An author bumps --menagerie_ref and the sha-checks tell them immediately whether the robot definition moved under them.

The env is the real arm — we do not simplify it

real_loop.py#envsha256:ccb40c0414…
# The SO-101 reach env, wrapping the Menagerie model. This is the "real arm's body": we do NOT# simplify the kinematics — the six sts3215 position servos, link lengths, and joint limits are# the manufacturer's. reset() respawns the box on the floor in a reachable patch (via its# freejoint, exactly as ch1.3's env writes cube qpos), and obs/step/dist mirror the PushT/ALOHA# env contract so the BC loop below is unchanged from ch1.1. Bitwise-deterministic on one CPU.HOME = np.array([0.0, -1.57, 1.57, 1.0, 0.0, 0.0])  # a raised, out-of-the-way rest poseFRAME_SKIP = 10  # 100 Hz physics (timestep 0.005) / 10 Hz control  class SO101ReachEnv:    def __init__(self, scene_xml: Path):        self.model = mujoco.MjModel.from_xml_path(str(scene_xml))        self.data = mujoco.MjData(self.model)        self._box_qadr = self.model.jnt_qposadr[self.model.body("box").jntadr[0]]  # box freejoint qpos start (=6)        self._tip = self.model.site("gripperframe").id        self._box = self.model.body("box").id        self._lo = self.model.actuator_ctrlrange[:, 0]        self._hi = self.model.actuator_ctrlrange[:, 1]     def reset(self, seed: int) -> np.ndarray:        rng = np.random.Generator(np.random.PCG64(seed))        mujoco.mj_resetData(self.model, self.data)        self.data.qpos[:6] = HOME        bx, by = rng.uniform(0.26, 0.31), rng.uniform(-0.14, 0.14)  # a reachable floor patch in front        self.data.qpos[self._box_qadr:self._box_qadr + 3] = [bx, by, 0.03]        self.data.qpos[self._box_qadr + 3:self._box_qadr + 7] = [1, 0, 0, 0]        self.data.ctrl[:] = HOME        mujoco.mj_forward(self.model, self.data)        return self._obs()     def _obs(self) -> np.ndarray:        return np.concatenate([self.data.qpos[:6], self.data.body(self._box).xpos]).astype(np.float32)     def step(self, ctrl: np.ndarray) -> np.ndarray:        self.data.ctrl[:] = np.clip(ctrl, self._lo, self._hi)  # position servos track the target        for _ in range(FRAME_SKIP):            mujoco.mj_step(self.model, self.data)        return self._obs()     def gripper_to_box(self) -> float:        return float(np.linalg.norm(np.array(self.data.site(self._tip).xpos) - np.array(self.data.body(self._box).xpos)))  env = SO101ReachEnv(scene_path)

SO101ReachEnv is a thin wrapper, and thin is the point. Unlike the Phase-1 envs we authored line by line, here the physics, the kinematics, and the actuators are the manufacturer's. We add exactly two things: a box, respawned on the floor in a reachable patch each reset (via its free joint — the same trick ch1.3's env used to place its cube), and an obs/step contract that matches PushT and ALOHA so the behavior-cloning loop below is unchanged from ch1.1. The observation is nine numbers — six joint angles and the box's (x, y, z). The action is six joint-position targets: the SO-101's servos track them, exactly as a real STS3215 does.

Driving is teleoperation, scripted so CI can diff it

real_loop.py#expertsha256:64fa80c3f0…
# The scripted expert = your teleoperation, scripted so CI can diff runs. A leader arm hands you# joint targets; here a tiny controller computes them. The reach is mostly a fixed "lower-to-the-# table" pose (shoulder_lift/elbow/wrist), STEERED by shoulder_pan toward the box's azimuth — the# one joint that must depend on where the box is. It commands the TARGET pose every step and lets# the position servos interpolate (a stable feedback law, not an open-loop trajectory — that is why# the clone learns it without drifting). PAN_GAIN is a one-number fit of pan -> tip azimuth.REACH_POSE = np.array([0.2, 0.3, 0.7])  # shoulder_lift, elbow_flex, wrist_flex — lowers the gripper to the tablePAN_GAIN = 0.87  def expert_action(box_xyz: np.ndarray) -> np.ndarray:    """The 6-D joint-position target that reaches `box_xyz`. Deterministic in the box position."""    pan = -np.arctan2(box_xyz[1], box_xyz[0]) / PAN_GAIN  # aim shoulder_pan at the box's azimuth    return np.array([pan, REACH_POSE[0], REACH_POSE[1], REACH_POSE[2], 0.0, 0.3], dtype=np.float32)

On real hardware you would grab a leader arm and move it; a follower mirrors you, and the joint angles it reports are your actions (that is G1's lerobot-teleoperate). We can't replay a human hand deterministically, so a tiny controller stands in. The reach is almost entirely a fixed lower-to-the-table pose — shoulder-lift, elbow, wrist held at constants — steered by the one joint that must know where the box is: shoulder_pan, aimed at the box's azimuth. It commands the target pose every step and lets the position servos interpolate. That detail matters more than it looks: a stable feedback law (drive toward a fixed goal) is something a clone can learn from any starting state, where an open-loop trajectory (do X at step 3) drifts the moment the clone's timing slips. Exercise 3 has you write that shoulder_pan line.

Recording writes the real format — the same one hardware writes

real_loop.py#recordsha256:641c6a379a…
# Record a REAL LeRobotDataset — the identical create -> add_frame -> save_episode -> finalize# sequence ch0.4 and `lerobot-record` use, STATE-only (use_videos=False) so the bytes are# reproducible on CPU. Episode i places the box with env seed (seed + i): a different, repeatable# reach each time. We store the obs we ACTED ON (pre-step) — the off-by-one ch0.4 warned about.def build_features() -> dict:    return {"observation.state": {"dtype": "float32", "shape": (OBS_DIM,), "names": STATE_NAMES},            "action": {"dtype": "float32", "shape": (ACT_DIM,), "names": JOINT_NAMES}}  def record_demos(dataset_root: Path) -> None:    from lerobot.datasets.lerobot_dataset import LeRobotDataset  # lazy: pulls in torch/datasets     if dataset_root.exists():  # regenerate every run: a stale dir from another --seed is silent wrong data (ch1.1)        shutil.rmtree(dataset_root)    dataset = LeRobotDataset.create(repo_id="zero2robot/so101_reach", fps=10, features=build_features(),                                    root=dataset_root, robot_type="so101_follower", use_videos=False)    for i in range(args.demos):        obs = env.reset(args.seed + i)        target = expert_action(obs[6:9])  # the reach target for THIS box (constant over the episode)        for _ in range(args.steps):            dataset.add_frame({"observation.state": obs, "action": target, "task": TASK})            obs = env.step(target)        dataset.save_episode()    dataset.finalize()  # compute stats, write meta/*.parquet — a dataset you could push to the Hub  dataset_root = args.out / "dataset"record_demos(dataset_root)

This is not a schema we invented and keep in sync. It is LeRobotDataset.create → add_frame → save_episode → finalize — the actual pinned lerobot API, the identical call record.py made in ch0.4 and lerobot-record makes on hardware. Format parity with everything you have trained on all course is free by construction. We record state-only (use_videos=False) so the run is byte-reproducible on one CPU — and, honestly, because the missing camera is part of the reality gap we are about to name. We store the observation we acted on (pre-step), the off-by-one ch0.4 warned you about.

Training is ch1.1's behavior cloning, retargeted to six joints

real_loop.py#modelsha256:c4f49e53ec…
# Load the recorded dataset BACK and train BC on it — the clone never sees the expert, only the# recording (the whole point of the loop). The model is ch1.1's MLP, retargeted 9 -> 6. One change# matters for a robot: the six joints move over VERY different ranges (shoulder_lift swings ~1.7 rad,# shoulder_pan ~0.5), so we compute the MSE in NORMALIZED action space — otherwise the big joints# dominate the loss and the tiny box-dependent pan signal (the only thing that matters) is ignored.from lerobot.datasets.lerobot_dataset import LeRobotDataset  # noqa: E402 frames = LeRobotDataset("zero2robot/so101_reach", root=dataset_root).hf_dataset.with_format("numpy")obs_np = np.stack(frames["observation.state"]).astype(np.float32)  # (N, 9)act_np = np.stack(frames["action"]).astype(np.float32)             # (N, 6)obs_min, obs_max = obs_np.min(0), obs_np.max(0)obs_range = np.where(obs_max - obs_min < 1e-4, np.float32(1.0), obs_max - obs_min)act_min, act_max = act_np.min(0), act_np.max(0)act_range = np.where(act_max - act_min < 1e-4, np.float32(1.0), act_max - act_min)  class BCPolicy(nn.Module):    """obs float32[9] -> action float32[6]. Same 3-layer MLP as ch1.1; normalization lives inside    as buffers so the checkpoint / ONNX carry their own stats and the deploy loop feeds raw obs."""     def __init__(self, hidden_dim: int):        super().__init__()        self.net = nn.Sequential(            nn.Linear(OBS_DIM, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),            nn.Linear(hidden_dim, ACT_DIM),        )        for name, stat in [("obs_min", obs_min), ("obs_range", obs_range),                           ("act_min", act_min), ("act_range", act_range)]:            self.register_buffer(name, torch.from_numpy(np.asarray(stat, np.float32)))     def net_norm(self, obs: torch.Tensor) -> torch.Tensor:  # raw obs -> NORMALIZED action (train target)        normalized = 2.0 * (obs - self.obs_min) / self.obs_range - 1.0        return self.net(normalized.clamp(-1.0, 1.0))     def forward(self, obs: torch.Tensor) -> torch.Tensor:   # raw obs -> RAW joint targets (deploy / ONNX)        return (self.net_norm(obs) + 1.0) / 2.0 * self.act_range + self.act_min  policy = BCPolicy(args.hidden_dim).to(device)

The clone never sees the expert. It loads the recorded dataset back off disk and fits observation → action with plain MSE — the whole point of the loop is that training consumes your recording, not your recorder. The network is ch1.1's three-layer MLP, retargeted 9 → 6.

One change is load-bearing, and it is a lesson about robots specifically. The six joints move over wildly different ranges — shoulder-lift swings more than a radian on every reach, while the box-dependent shoulder_pan correction is a few tenths. Compute the MSE on raw joint angles and the big joints drown the tiny pan signal — the network learns to sweep down and ignore where the box is, and the clone reaches the same spot every time. So we take the loss in normalized action space, where every joint weighs equally and the pan signal survives. (You will feel the raw-space failure if you look for it; the normalized loss is why the clone works.)

real_loop.py#trainsha256:babc4def5d…
optimizer = torch.optim.Adam(policy.parameters(), lr=args.lr)train_obs = torch.from_numpy(obs_np).to(device)train_act = torch.from_numpy(act_np).to(device)# The MSE target is the NORMALIZED action, so every joint weighs equally (see the model region).target_norm = 2.0 * (train_act - policy.act_min) / policy.act_range - 1.0shuffle = torch.Generator().manual_seed(args.seed)train_loss, global_step = float("nan"), 0for epoch in range(args.epochs):    epoch_loss, num_batches = 0.0, 0    for batch in torch.randperm(len(train_obs), generator=shuffle).split(args.batch_size):        loss = nn.functional.mse_loss(policy.net_norm(train_obs[batch]), target_norm[batch])        optimizer.zero_grad()        loss.backward()        optimizer.step()        epoch_loss, num_batches = epoch_loss + loss.item(), num_batches + 1        if args.rerun:            rr.set_time("step", sequence=global_step)            rr.log("policy/loss/train", rr.Scalars([loss.item()]))        global_step += 1    train_loss = epoch_loss / num_batches    if epoch % 50 == 0 or epoch == args.epochs - 1:        print(f"epoch {epoch:3d}  train_loss {train_loss:.5f}")torch.save(policy, args.out / "bc_policy.pt")

Deploying is four lines, and evaluating is ch1.6

real_loop.py#deploysha256:c208b81c3a…
# Deploy the clone into the sim SO-101 (policy -> d.ctrl) and evaluate with ch1.6 rigor: a success# RATE over held-out box placements (seeds 10_000+, never trained on), not one hero rollout. We run# the clone AND two baselines that must FAIL — a no-op that holds the rest pose, and a random-target# flail — so the headline is a DIRECTION (clone >> baselines), the seed-robust claim. --break obs_swap# feeds the clone an observation whose box_x/box_y are transposed vs the recording: the ch0.4 lesson,# now on a real arm — training is spotless, the arm reaches the wrong way.def rollout(kind: str, episode: int) -> tuple[bool, float, np.ndarray]:    obs = env.reset(10_000 + episode)  # held-out placements, disjoint from the seed+i training set    rng = np.random.Generator(np.random.PCG64(args.seed * 1000 + episode))    tips = []    for _ in range(args.steps):        model_in = obs.copy()        if kind == "clone" and args.break_mode == "obs_swap":            model_in[6], model_in[7] = model_in[7], model_in[6]  # deploy obs != record obs        if kind == "noop":            ctrl = HOME.astype(np.float32)        elif kind == "random":            ctrl = rng.uniform(env._lo, env._hi).astype(np.float32)        else:            with torch.no_grad():                ctrl = policy(torch.from_numpy(model_in).to(device).unsqueeze(0))[0].cpu().numpy()        obs = env.step(ctrl)        tips.append(np.array(env.data.site(env._tip).xpos))    return env.gripper_to_box() < args.success_tol, env.gripper_to_box(), np.array(tips)  def success_rate(kind: str) -> float:    return sum(rollout(kind, e)[0] for e in range(args.eval_episodes)) / args.eval_episodes  clone_rate = success_rate("clone")noop_rate = success_rate("noop")random_rate = success_rate("random")print(f"eval: clone {clone_rate:.2f} | no-op {noop_rate:.2f} | random {random_rate:.2f}  "      f"(success = gripper within {args.success_tol} m of the box, break={args.break_mode or 'none'})") # Capture two gripper-tip paths for the toy NOW, while the policy is still on `device` (the ONNX# export below moves it to CPU): the clone reaching the box, and the same box mis-wired (obs_swap)._, _, clone_tips = rollout("clone", 0)saved_break, args.break_mode = args.break_mode, "obs_swap"_, _, swap_tips = rollout("clone", 0)args.break_mode = saved_breakbox0 = env.reset(10_000)[6:9]

obs → policy → d.ctrl → step. That is the entire deployment surface — the sim mirror of the get_observation → select_action → send_action loop G1 shows you on real hardware. Everything hard you built in Phase 1 exists to make one of those lines good; the rest is the robot.

And we evaluate it the ch1.6 way: a success rate over held-out box placements (seeds the clone never trained on), against two baselines that must fail — a no-op that holds the rest pose, and a random flail. The headline is a direction, not a number: the recorded-then-cloned policy reproduces the reach clearly above both baselines, on every seed. That is the whole claim — the loop closes end-to-end on the real arm's body. It is a claim about the loop, not about manipulation being solved, and we report the order because the exact rate shifts with the platform's contact and servo settling (ch1.6). The clone matching the scripted expert is the loop working, not a performance flex — a scripted reach is an easy, low-variance target, which is exactly the right bar for a mechanism demo.

What the twin gives you — and what it cannot

Here is the honest core, and it is the reason this chapter is the second-to-last thing you do, not the last.

What you just did transfers to hardware unchanged. You drove a real arm's morphology, recorded the real LeRobot dataset format, trained a policy on it, and redeployed it through the real control loop. Buy the SO-101 and every one of those skills is already yours — that is the entire thesis of G1.

What the twin cannot give you is the reality gap, and you must not let a clean 100% here paper over it. Servo backlash. Friction that isn't in the model. Latency between reading an observation and the motor moving. Camera noise and lighting and a background that shifts at 6pm. Calibration drift between two "identical" arms. The Menagerie model even tells you this itself: its servo gains are a calculated approximation, not the real STS3215 gains — the MJCF says so in a comment. This chapter is morphology plus the loop, not torque-level fidelity, and it reaches for a box rather than pinching and lifting it precisely because a frictional grasp is where the gap bites hardest. Those gaps are not a footnote. They are the whole reason real hardware exists, and closing them is what your weeks on a real arm will be spent on. They stay reading — that is G1.

Break it the way the metal breaks

python real_loop.py --seed 0 --break obs_swap

The most common way a working policy fails on a real robot is not a bad model — it is a bad wiring between your recorder and your deploy script. --break obs_swap simulates exactly that: the deploy code reads box_x and box_y in the opposite order than the recording wrote them. Training is untouched — same dataset, same clean loss near 1e-5. And the arm confidently reaches the wrong way, its success collapsing from ~1.00 to ~0.20. No loss curve could have warned you, because training never saw the swap; the bug lives entirely at the seam where your policy meets the world. This is the ch0.4 lesson — record obs must equal deploy obs — now with a physical consequence, and it is why G1 insists your calibration id matches across record, train, and rollout. Exercise 2 makes you generate this failure and explain why every training metric you watched stayed green.

Where you are

You have run the whole loop, from a blank robot model to a deployed policy, on a real arm's body. The last gap — the reality gap — is the one thing sim could not hand you, and it is the one thing worth crossing on real hardware. Two bridges remain: G1, where you buy the arm and run this exact loop on the metal, and G2/G3, where you decide what your robot should do. You did not need to wait for a robot to learn robotics. You needed the robot to find out you already had.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch5.8.

    Objective tested: does the LOOP actually close? real_loop.py runs the whole LeRobot pipeline on the SO-101's real body — it DRIVES a scripted reach, RECORDS a real LeRobotDataset, TRAINS a BC clone on that recording, and DEPLOYS the clone back into the sim. It then reports three success rates in metrics.json, each over the SAME held-out box placements:

    • clone_success_rate : the recorded-then-cloned BC policy
    • noop_success_rate : hold the rest pose (do nothing)
    • random_success_rate : random joint targets (flail)

    PREDICT before you run: how do the three order?

    • A) clone ~= no-op ~= random — cloning a scripted reach from 64 recorded episodes doesn't transfer; the deployed policy is no better than doing nothing.

    • B) clone >> no-op ~ random — the clone reproduces the scripted reach (well above both baselines), i.e. the record -> train -> deploy loop closes end-to-end on the real arm's body.

    • C) no-op > clone — holding still is closer to the box than the trained policy gets.

    It runs the FULL loop at the default config (~1 min on a CPU laptop; the SO-101 model is fetched once, ~17MB, then cached). The claim to internalize is the DIRECTION (clone >> baselines), which holds on every seed — NOT the exact rate, which shifts with the platform's contact/servo settling and the held-out sample (ch1.6). And the honest limit: the clone matching the expert is the LOOP closing, not manipulation being solved — the reality gap (backlash, friction, camera OOD) is what G1 and real hardware are for. Estimated learner time: 15 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  2. predict-then-run

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — predict-then-run + learner-generated failure, ch5.8.

    Objective tested: the bug that only shows up at DEPLOY time — the ch0.4 lesson, now on a real arm. The recorder wrote each observation as [6 joints, box_x, box_y, box_z]. Your deploy script must feed the policy observations in the SAME layout. --break obs_swap simulates the classic mistake: the deploy code wires box_x and box_y in the OPPOSITE order than the recording did. The training run is untouched — same dataset, same clean loss (~1e-5). Only the deployed observation is mis-wired.

    You will run the loop twice and compare metrics.json: a clean deploy, and --break obs_swap.

    PREDICT before you run: what happens to clone_success_rate under obs_swap, and what happens to final_train_loss?

    • A) BOTH collapse — a bad observation wiring corrupts training too, so the loss blows up and the policy never learns; the failure is visible in the loss curve.

    • B) NEITHER changes — the policy is robust to which axis is which; swapping box_x/box_y is harmless because the reach only needs the box's distance, not its direction.

    • C) final_train_loss is UNCHANGED (~1e-5, training never saw the swap) but clone_success_rate COLLAPSES — the arm confidently reaches the wrong way, and no loss curve could have warned you.

    It runs the FULL loop twice (~2 min CPU). This is the failure you GENERATE and diagnose: a train/deploy contract mismatch is invisible to every metric you watch during training and only appears when the policy meets the world — which on real hardware is a robot lunging at the wrong spot. It is why G1 insists your calibration id matches across record, train, and rollout. Estimated learner time: 20 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  3. code-completion

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — code-completion, ch5.8.

    Objective tested: the ONE joint that must depend on where the box is. The scripted expert that drives the SO-101 to the box is mostly a FIXED "lower-to-the-table" pose — shoulder_lift, elbow_flex, wrist_flex held at constants — steered by exactly one joint: shoulder_pan, which must aim the arm at the box's azimuth. Get that one number right and the recorded demos teach a reach; get it wrong (or constant) and every demo drives to the same spot and the clone can't reach a box that moves.

    Your job: implement expert_action(box_xyz) — return the 6-D joint-position target [shoulder_pan, shoulder_lift, elbow_flex, wrist_flex, wrist_roll, gripper] that reaches a box at box_xyz. Five of the six are the fixed reach constants below. The sixth, shoulder_pan, is the azimuth of the box in the base frame, mapped through PAN_GAIN (a one-number fit of pan -> tip azimuth measured from the model): pan = -atan2(box_y, box_x) / PAN_GAIN.

    Implement it below (pure numpy, no MuJoCo needed), then run the checks: pytest curriculum/phase5_practitioner/ch5.8_real_loop/exercises/suggested/checks.py -k ex3 Estimated learner time: 10 minutes.

    Run it locally:

    pytest curriculum/phase5_practitioner/ch5.8_real_loop/exercises/suggested/checks.py -k ex3
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~0.88 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4wall-clock on t4: not yet measuredpending
4090wall-clock on 4090: not yet measuredpending

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromreal_loop.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
e58f7a666e25b3f582c14239648407a33cd8ba7734721e0a31ebfa413183f0d1
#fetch
897b8fa177a95b0323940b7e1acaa44da50f3c8a81f834067d56f487ecb93182
#env
ccb40c0414ecc877b1bf003c801e66b2d0a8b4d558a8673761bffc74599581a3
#expert
64fa80c3f028c06be6234295e0e580f1ab057c944744a27e1f836f3c487e6997
#record
641c6a379ae8003e4889bdda0e5fd6aa9dbce2471c023d3ca28cae050dd4544e
#model
c4f49e53eca845ca86897d749621cc1b5c057da12d3f91f5021df6ef7a536db7
#train
babc4def5d523b4d8ed542aa1ba7d43f996c86af42898106412e3caa330ce41d
#deploy
c208b81c3a5241698092343e1d699e3654e4686a8d7b74d7a04909a612de3016
#report
6ebfaccf20404c7bc1718cf50320866b6743d26c77a907712e93044975298ef9